mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
UX: improved tag editor
- Tags can now be rearranged in the editor and will be displayed in that order in the component - The component now displays as many tags as it can fill in the available space instead of showing 1 and hiding the rest under the "+X" - Increased click area for the color selector and edit buttons
This commit is contained in:
1 parent
e65d67cf06
commit
fed49ceede
19 files changed
+794
-175
No files matched your search
@@ -292,7 +292,7 @@ onUnmounted(() => {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-list-move {
|
||||
.sortable-list--dragging .sortable-list-move {
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getTagOverflowVisibleCount } from '../tag-overflow-list';
|
||||
|
||||
const GAP = 4;
|
||||
const MORE_BADGE_WIDTH = 24;
|
||||
|
||||
function overflow(tagWidths: number[], availableWidth: number) {
|
||||
return getTagOverflowVisibleCount({
|
||||
tagWidths,
|
||||
moreBadgeWidth: MORE_BADGE_WIDTH,
|
||||
availableWidth,
|
||||
gap: GAP,
|
||||
});
|
||||
}
|
||||
|
||||
describe('getTagOverflowVisibleCount', () => {
|
||||
it('returns no badges when there are no tags', () => {
|
||||
expect(overflow([], 200)).toEqual({
|
||||
visibleCount: 0,
|
||||
hiddenCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows every tag when they fit without a more badge', () => {
|
||||
expect(overflow([40, 40, 40], 128)).toEqual({
|
||||
visibleCount: 3,
|
||||
hiddenCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows +X only for tags that do not fit', () => {
|
||||
expect(overflow([40, 40, 40], 112)).toEqual({
|
||||
visibleCount: 2,
|
||||
hiddenCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the first tag when only that tag plus +X fit', () => {
|
||||
expect(overflow([40, 40, 40], 70)).toEqual({
|
||||
visibleCount: 1,
|
||||
hiddenCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps one tag when the first tag is wider than the row', () => {
|
||||
expect(overflow([200, 40], 80)).toEqual({
|
||||
visibleCount: 1,
|
||||
hiddenCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show +X for a single tag that overflows', () => {
|
||||
expect(overflow([200], 80)).toEqual({
|
||||
visibleCount: 1,
|
||||
hiddenCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows every tag before the row has a measured width', () => {
|
||||
expect(overflow([40, 40], 0)).toEqual({
|
||||
visibleCount: 2,
|
||||
hiddenCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,5 @@
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export { default as TagSelector } from './tag-selector.vue';
|
||||
export { default as TagOverflowList } from './tag-overflow-list.vue';
|
||||
export type { TagOverflowItem } from './tag-overflow-list';
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export const TAG_OVERFLOW_GAP_PX = 4;
|
||||
|
||||
export interface TagOverflowItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
style?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TagOverflowVisibleCount {
|
||||
visibleCount: number;
|
||||
hiddenCount: number;
|
||||
}
|
||||
|
||||
export function getTagOverflowVisibleCount(options: {
|
||||
tagWidths: number[];
|
||||
moreBadgeWidth: number;
|
||||
availableWidth: number;
|
||||
gap?: number;
|
||||
}): TagOverflowVisibleCount {
|
||||
const tagWidths = options.tagWidths;
|
||||
const tagCount = tagWidths.length;
|
||||
const gap = options.gap ?? TAG_OVERFLOW_GAP_PX;
|
||||
|
||||
if (tagCount === 0) {
|
||||
return {
|
||||
visibleCount: 0,
|
||||
hiddenCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.availableWidth <= 0) {
|
||||
return {
|
||||
visibleCount: tagCount,
|
||||
hiddenCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let totalWidth = gap * Math.max(0, tagCount - 1);
|
||||
|
||||
for (const tagWidth of tagWidths) {
|
||||
totalWidth += tagWidth;
|
||||
}
|
||||
|
||||
if (totalWidth <= options.availableWidth) {
|
||||
return {
|
||||
visibleCount: tagCount,
|
||||
hiddenCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let usedWidth = 0;
|
||||
let visibleCount = 0;
|
||||
|
||||
for (const tagWidth of tagWidths) {
|
||||
if (visibleCount === 0) {
|
||||
usedWidth = tagWidth;
|
||||
visibleCount = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const widthWithTagAndMore = usedWidth + gap + tagWidth + gap + options.moreBadgeWidth;
|
||||
|
||||
if (widthWithTagAndMore <= options.availableWidth) {
|
||||
usedWidth += gap + tagWidth;
|
||||
visibleCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
visibleCount,
|
||||
hiddenCount: tagCount - visibleCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||
License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import { getTagOverflowVisibleCount, type TagOverflowItem } from './tag-overflow-list';
|
||||
|
||||
const props = defineProps<{
|
||||
tags: TagOverflowItem[];
|
||||
badgeClass?: string;
|
||||
}>();
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
const measureRef = ref<HTMLElement | null>(null);
|
||||
const visibleCount = ref(props.tags.length);
|
||||
const hiddenCount = ref(0);
|
||||
|
||||
const visibleTags = computed(() => props.tags.slice(0, visibleCount.value));
|
||||
const tagsMeasureKey = computed(() => {
|
||||
return props.tags.map(tag => `${tag.id}\0${tag.name}`).join('\n');
|
||||
});
|
||||
|
||||
function badgeStyle(tag: TagOverflowItem): Record<string, string> | undefined {
|
||||
if (tag.style) {
|
||||
return tag.style;
|
||||
}
|
||||
|
||||
if (!tag.color) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: `${tag.color}25`,
|
||||
color: tag.color,
|
||||
};
|
||||
}
|
||||
|
||||
function badgeClassName(options: { canShrink: boolean }): string {
|
||||
return [
|
||||
'tag-selector__badge',
|
||||
'tag-overflow-list__badge',
|
||||
options.canShrink ? 'tag-overflow-list__badge--shrink' : '',
|
||||
props.badgeClass ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function updateOverflow(): void {
|
||||
const root = rootRef.value;
|
||||
const measure = measureRef.value;
|
||||
|
||||
if (!root || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagElements = [...measure.querySelectorAll<HTMLElement>('[data-tag-overflow-measure]')];
|
||||
const moreElement = measure.querySelector<HTMLElement>('[data-tag-overflow-measure-more]');
|
||||
const nextOverflow = getTagOverflowVisibleCount({
|
||||
tagWidths: tagElements.map(element => Math.ceil(element.getBoundingClientRect().width)),
|
||||
moreBadgeWidth: moreElement ? Math.ceil(moreElement.getBoundingClientRect().width) : 0,
|
||||
availableWidth: root.clientWidth,
|
||||
});
|
||||
|
||||
if (
|
||||
nextOverflow.visibleCount === visibleCount.value
|
||||
&& nextOverflow.hiddenCount === hiddenCount.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
visibleCount.value = nextOverflow.visibleCount;
|
||||
hiddenCount.value = nextOverflow.hiddenCount;
|
||||
}
|
||||
|
||||
useResizeObserver(rootRef, () => {
|
||||
updateOverflow();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updateOverflow();
|
||||
});
|
||||
|
||||
watch(tagsMeasureKey, () => {
|
||||
visibleCount.value = props.tags.length;
|
||||
hiddenCount.value = 0;
|
||||
void nextTick(() => {
|
||||
updateOverflow();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="tag-overflow-list"
|
||||
>
|
||||
<div
|
||||
ref="measureRef"
|
||||
class="tag-overflow-list__measure"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="`measure-${tag.id}`"
|
||||
class="tag-selector__badge"
|
||||
data-tag-overflow-measure
|
||||
>{{ tag.name }}</span>
|
||||
<span
|
||||
class="tag-selector__badge tag-selector__badge--more"
|
||||
data-tag-overflow-measure-more
|
||||
>+{{ tags.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tag-overflow-list__visible">
|
||||
<span
|
||||
v-for="tag in visibleTags"
|
||||
:key="tag.id"
|
||||
:class="badgeClassName({ canShrink: visibleTags.length === 1 })"
|
||||
:style="badgeStyle(tag)"
|
||||
>{{ tag.name }}</span>
|
||||
<span
|
||||
v-if="hiddenCount > 0"
|
||||
class="tag-selector__badge tag-selector__badge--more tag-overflow-list__more"
|
||||
:class="badgeClass"
|
||||
>
|
||||
+{{ hiddenCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.tag-overflow-list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-overflow-list__measure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
inset-inline-start: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-overflow-list__measure .tag-selector__badge {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.tag-overflow-list__visible {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-overflow-list__badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-overflow-list__badge--shrink {
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.tag-overflow-list__more {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,13 @@ Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CheckIcon, CirclePlusIcon, PencilIcon, TagIcon } from '@lucide/vue';
|
||||
import {
|
||||
CheckIcon,
|
||||
CirclePlusIcon,
|
||||
GripVerticalIcon,
|
||||
PencilIcon,
|
||||
TagIcon,
|
||||
} from '@lucide/vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
@@ -14,20 +20,22 @@ import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import { SortableList } from '@/components/sortable-list';
|
||||
import type { ItemTag } from '@/types/user-stats';
|
||||
import type { PopoverContentProps } from 'reka-ui';
|
||||
import { useTagInlineEditor, stopSpaceKeyPropagation } from '@/composables/use-tag-inline-editor';
|
||||
import { haveSameKeyOrder, reorderMatchingItems } from '@/utils/reorder-matching-items';
|
||||
import TagOverflowList from './tag-overflow-list.vue';
|
||||
import type { TagOverflowItem } from './tag-overflow-list';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tags: ItemTag[];
|
||||
selectedTagIds: string[];
|
||||
allowCreate?: boolean;
|
||||
triggerVariant?: 'default' | 'compact' | 'icon';
|
||||
maxBadges?: number;
|
||||
fullWidth?: boolean;
|
||||
openOnMount?: boolean;
|
||||
align?: PopoverContentProps['align'];
|
||||
@@ -37,7 +45,6 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
allowCreate: true,
|
||||
triggerVariant: 'default',
|
||||
maxBadges: 2,
|
||||
fullWidth: false,
|
||||
openOnMount: false,
|
||||
align: 'start',
|
||||
@@ -51,6 +58,7 @@ const emit = defineEmits<{
|
||||
'create-tag': [name: string];
|
||||
'rename-tag': [tagId: string, name: string];
|
||||
'update-tag-color': [tagId: string, color: string];
|
||||
'reorder-tags': [tags: ItemTag[]];
|
||||
'open-change': [open: boolean];
|
||||
}>();
|
||||
|
||||
@@ -70,6 +78,8 @@ const {
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
startEdit,
|
||||
onToggleControlPointerDown,
|
||||
onColorClick,
|
||||
onColorInput,
|
||||
onColorBlur,
|
||||
resetEditState,
|
||||
@@ -100,10 +110,45 @@ const selectedTags = computed(() => {
|
||||
return props.tags.filter(tag => selectedTagIdsSet.value.has(tag.id));
|
||||
});
|
||||
|
||||
const selectedOverflowTags = computed<TagOverflowItem[]>(() => {
|
||||
return selectedTags.value.map(tag => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: displayColor(tag),
|
||||
}));
|
||||
});
|
||||
|
||||
function getTagKey(tag: ItemTag): string {
|
||||
return tag.id;
|
||||
}
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
emit('toggle-tag', tagId);
|
||||
}
|
||||
|
||||
function handleTagsReorder(nextVisibleTags: ItemTag[]) {
|
||||
const nextTags = reorderMatchingItems(props.tags, nextVisibleTags, getTagKey);
|
||||
|
||||
if (haveSameKeyOrder(props.tags, nextTags, getTagKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('reorder-tags', nextTags);
|
||||
}
|
||||
|
||||
function handleTagsDragEnd() {
|
||||
function swallowClick(clickEvent: MouseEvent) {
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
window.removeEventListener('click', swallowClick, true);
|
||||
}
|
||||
|
||||
window.addEventListener('click', swallowClick, true);
|
||||
window.setTimeout(() => {
|
||||
window.removeEventListener('click', swallowClick, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onSelectTag(tag: ItemTag) {
|
||||
if (editingTagId.value === tag.id) {
|
||||
return;
|
||||
@@ -168,23 +213,8 @@ function clearSearch() {
|
||||
>
|
||||
<TagIcon class="tag-selector__trigger-icon-plus" />
|
||||
<span class="tag-selector__label">{{ t('tags.editTags') }}</span>
|
||||
<template v-if="selectedTags.length > 0">
|
||||
<div class="tag-selector__selected-tags">
|
||||
<span
|
||||
v-for="tag in selectedTags.slice(0, maxBadges)"
|
||||
:key="tag.id"
|
||||
class="tag-selector__badge"
|
||||
:style="{ backgroundColor: displayColor(tag) + '25', color: displayColor(tag) }"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedTags.length > maxBadges"
|
||||
class="tag-selector__badge tag-selector__badge--more"
|
||||
>
|
||||
+{{ selectedTags.length - maxBadges }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="selectedOverflowTags.length > 0">
|
||||
<TagOverflowList :tags="selectedOverflowTags" />
|
||||
</template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -222,73 +252,91 @@ function clearSearch() {
|
||||
</Button>
|
||||
</div>
|
||||
<CommandGroup v-if="filteredTags.length > 0">
|
||||
<CommandItem
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
:value="tag.name"
|
||||
class="tag-selector__item"
|
||||
@select="() => onSelectTag(tag)"
|
||||
<SortableList
|
||||
class="tag-selector__sortable"
|
||||
:items="filteredTags"
|
||||
:get-key="getTagKey"
|
||||
handle-selector=".tag-selector__drag-handle"
|
||||
@set="handleTagsReorder"
|
||||
@drag-end="handleTagsDragEnd"
|
||||
>
|
||||
<div
|
||||
class="tag-selector__checkbox"
|
||||
:data-selected="selectedTagIdsSet.has(tag.id) || undefined"
|
||||
:style="selectedTagIdsSet.has(tag.id) ? { borderColor: displayColor(tag) + '80', backgroundColor: displayColor(tag) + '20' } : undefined"
|
||||
>
|
||||
<CheckIcon
|
||||
class="tag-selector__check"
|
||||
:style="{ color: displayColor(tag) }"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
class="tag-selector__color-dot-wrap"
|
||||
:title="t('tags.tagColor')"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="tag-selector__color-dot-hitbox">
|
||||
<input
|
||||
type="color"
|
||||
class="tag-selector__color-input"
|
||||
:value="colorHexForPicker(displayColor(tag))"
|
||||
@click.stop
|
||||
@input="onColorInput($event, tag.id)"
|
||||
@blur="onColorBlur"
|
||||
>
|
||||
<span
|
||||
class="tag-selector__color-dot"
|
||||
<template #item="{ item: tag }">
|
||||
<div class="sigma-ui-command-item tag-selector__item">
|
||||
<button
|
||||
type="button"
|
||||
class="tag-selector__drag-handle"
|
||||
aria-hidden="true"
|
||||
:style="{ backgroundColor: displayColor(tag) }"
|
||||
/>
|
||||
tabindex="-1"
|
||||
@click.stop
|
||||
>
|
||||
<GripVerticalIcon :size="14" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tag-selector__checkbox"
|
||||
:data-selected="selectedTagIdsSet.has(tag.id) || undefined"
|
||||
:style="selectedTagIdsSet.has(tag.id) ? { borderColor: displayColor(tag) + '80', backgroundColor: displayColor(tag) + '20' } : undefined"
|
||||
@click.stop="onSelectTag(tag)"
|
||||
>
|
||||
<CheckIcon
|
||||
class="tag-selector__check"
|
||||
:style="{ color: displayColor(tag) }"
|
||||
/>
|
||||
</button>
|
||||
<label
|
||||
class="tag-selector__color-dot-wrap"
|
||||
:title="t('tags.tagColor')"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="tag-selector__color-dot-hitbox">
|
||||
<input
|
||||
type="color"
|
||||
class="tag-selector__color-input"
|
||||
:value="colorHexForPicker(displayColor(tag))"
|
||||
@click.stop="onColorClick($event, tag)"
|
||||
@pointerdown.stop="onToggleControlPointerDown($event, tag)"
|
||||
@input="onColorInput($event, tag.id)"
|
||||
@blur="onColorBlur"
|
||||
>
|
||||
<span
|
||||
class="tag-selector__color-dot"
|
||||
aria-hidden="true"
|
||||
:style="{ backgroundColor: displayColor(tag) }"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<span
|
||||
v-if="editingTagId !== tag.id"
|
||||
class="tag-selector__tag-name"
|
||||
>{{ tag.name }}</span>
|
||||
<input
|
||||
v-else
|
||||
:ref="setRenameInputRef"
|
||||
v-model="editDraft"
|
||||
class="sigma-ui-input tag-selector__rename-input"
|
||||
autofocus
|
||||
@keydown="stopSpaceKeyPropagation"
|
||||
@keydown.enter.prevent="commitEdit"
|
||||
@keydown.esc.prevent="cancelEdit"
|
||||
@blur="commitEdit"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="tag-selector__item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="tag-selector__edit"
|
||||
:title="t('tags.renameTag')"
|
||||
@pointerdown="onToggleControlPointerDown($event, tag)"
|
||||
@click="startEdit($event, tag)"
|
||||
>
|
||||
<PencilIcon :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<span
|
||||
v-if="editingTagId !== tag.id"
|
||||
class="tag-selector__tag-name"
|
||||
>{{ tag.name }}</span>
|
||||
<input
|
||||
v-else
|
||||
:ref="setRenameInputRef"
|
||||
v-model="editDraft"
|
||||
class="sigma-ui-input tag-selector__rename-input"
|
||||
autofocus
|
||||
@keydown="stopSpaceKeyPropagation"
|
||||
@keydown.enter.prevent="commitEdit"
|
||||
@keydown.esc.prevent="cancelEdit"
|
||||
@blur="commitEdit"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="tag-selector__item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="tag-selector__edit"
|
||||
:title="t('tags.renameTag')"
|
||||
@click="(event) => startEdit(event, tag)"
|
||||
>
|
||||
<PencilIcon :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</template>
|
||||
</SortableList>
|
||||
</CommandGroup>
|
||||
<CommandSeparator v-if="$slots.footer" />
|
||||
<div
|
||||
@@ -359,14 +407,6 @@ function clearSearch() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-selector__selected-tags {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-selector__badge {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
@@ -414,20 +454,18 @@ function clearSearch() {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0;
|
||||
border: 1.5px solid hsl(var(--muted-foreground) / 70%);
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
opacity: 0.6;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-selector__checkbox:not([data-selected]) .tag-selector__check {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tag-selector__checkbox[data-selected] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tag-selector__check {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -445,10 +483,11 @@ function clearSearch() {
|
||||
.tag-selector__color-dot-hitbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.tag-selector__color-input {
|
||||
@@ -477,10 +516,45 @@ function clearSearch() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-selector__sortable :deep(.sortable-list__items) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tag-selector__item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag-selector__item:hover {
|
||||
background-color: hsl(var(--secondary));
|
||||
color: hsl(var(--popover-foreground) / 80%);
|
||||
}
|
||||
|
||||
.tag-selector__drag-handle {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-right: 4px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.tag-selector__drag-handle:hover {
|
||||
background-color: hsl(var(--muted) / 40%);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.tag-selector__drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tag-selector__rename-input {
|
||||
min-width: 0;
|
||||
height: 1.75rem;
|
||||
@@ -500,9 +574,11 @@ function clearSearch() {
|
||||
|
||||
.tag-selector__edit {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useTagInlineEditor } from '@/composables/use-tag-inline-editor';
|
||||
import type { ItemTag } from '@/types/user-stats';
|
||||
|
||||
function createTag(id: string): ItemTag {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
color: '#3b82f6',
|
||||
};
|
||||
}
|
||||
|
||||
function createEditor(tags: ItemTag[] = [createTag('work')]) {
|
||||
return useTagInlineEditor({
|
||||
tags: ref(tags),
|
||||
onRename: vi.fn(),
|
||||
onUpdateColor: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function createCancelableEvent() {
|
||||
return new Event('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('useTagInlineEditor', () => {
|
||||
it('opens the rename input from the edit button', () => {
|
||||
const editor = createEditor();
|
||||
const tag = createTag('work');
|
||||
|
||||
editor.startEdit(createCancelableEvent(), tag);
|
||||
|
||||
expect(editor.editingTagId.value).toBe('work');
|
||||
expect(editor.editDraft.value).toBe('work');
|
||||
});
|
||||
|
||||
it('closes the rename input when the edit button is pressed again', () => {
|
||||
const editor = createEditor();
|
||||
const tag = createTag('work');
|
||||
|
||||
editor.startEdit(createCancelableEvent(), tag);
|
||||
editor.onToggleControlPointerDown(createCancelableEvent(), tag);
|
||||
editor.startEdit(createCancelableEvent(), tag);
|
||||
|
||||
expect(editor.editingTagId.value).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the rename input when the color control is pressed again', () => {
|
||||
const editor = createEditor();
|
||||
const tag = createTag('work');
|
||||
const colorClick = createCancelableEvent();
|
||||
|
||||
editor.startEdit(createCancelableEvent(), tag);
|
||||
editor.onToggleControlPointerDown(createCancelableEvent(), tag);
|
||||
editor.onColorClick(colorClick, tag);
|
||||
|
||||
expect(editor.editingTagId.value).toBeNull();
|
||||
expect(colorClick.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the rename input from the color control', () => {
|
||||
const editor = createEditor();
|
||||
const tag = createTag('work');
|
||||
const colorClick = createCancelableEvent();
|
||||
|
||||
editor.onColorClick(colorClick, tag);
|
||||
|
||||
expect(editor.editingTagId.value).toBe('work');
|
||||
expect(colorClick.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export function useTagInlineEditor(options: {
|
||||
const renameInputRef = ref<HTMLInputElement | null>(null);
|
||||
const previewTagColors = ref<Record<string, string>>({});
|
||||
const pendingTagColors = ref<Record<string, string>>({});
|
||||
let skipClickToggle = false;
|
||||
|
||||
function displayColor(tag: ItemTag): string {
|
||||
return previewTagColors.value[tag.id] ?? tag.color;
|
||||
@@ -103,15 +104,7 @@ export function useTagInlineEditor(options: {
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
function startEdit(event: Event, tag: ItemTag) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (editingTagId.value === tag.id) {
|
||||
commitEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
function beginEdit(tag: ItemTag) {
|
||||
if (editingTagId.value !== null && editingTagId.value !== tag.id) {
|
||||
commitEdit();
|
||||
}
|
||||
@@ -124,6 +117,55 @@ export function useTagInlineEditor(options: {
|
||||
});
|
||||
}
|
||||
|
||||
function consumeSkipClickToggle(): boolean {
|
||||
if (!skipClickToggle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
skipClickToggle = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function onToggleControlPointerDown(event: Event, tag: ItemTag) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (editingTagId.value !== tag.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
skipClickToggle = true;
|
||||
}
|
||||
|
||||
function startEdit(event: Event, tag: ItemTag) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (consumeSkipClickToggle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingTagId.value === tag.id) {
|
||||
commitEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
beginEdit(tag);
|
||||
}
|
||||
|
||||
function onColorClick(event: Event, tag: ItemTag) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (consumeSkipClickToggle() || editingTagId.value === tag.id) {
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
beginEdit(tag);
|
||||
}
|
||||
|
||||
function deleteTag(event: Event, tagId: string) {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -219,6 +261,8 @@ export function useTagInlineEditor(options: {
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
startEdit,
|
||||
onToggleControlPointerDown,
|
||||
onColorClick,
|
||||
deleteTag,
|
||||
onColorInput,
|
||||
onColorBlur,
|
||||
|
||||
@@ -33,6 +33,8 @@ const {
|
||||
cancelEdit,
|
||||
commitEdit,
|
||||
startEdit,
|
||||
onToggleControlPointerDown,
|
||||
onColorClick,
|
||||
deleteTag,
|
||||
onColorInput,
|
||||
onColorBlur,
|
||||
@@ -57,7 +59,8 @@ const {
|
||||
type="color"
|
||||
class="tag-selector__color-input"
|
||||
:value="colorHexForPicker(displayColor(tag))"
|
||||
@click.stop
|
||||
@click.stop="onColorClick($event, tag)"
|
||||
@pointerdown.stop="onToggleControlPointerDown($event, tag)"
|
||||
@input="onColorInput($event, tag.id)"
|
||||
@blur="onColorBlur"
|
||||
>
|
||||
@@ -90,9 +93,10 @@ const {
|
||||
type="button"
|
||||
class="tag-selector__edit"
|
||||
:title="t('tags.renameTag')"
|
||||
@pointerdown="onToggleControlPointerDown($event, tag)"
|
||||
@click="startEdit($event, tag)"
|
||||
>
|
||||
<PencilIcon :size="14" />
|
||||
<PencilIcon :size="16" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -111,7 +115,7 @@ const {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 24px;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-block: 2px;
|
||||
@@ -129,9 +133,11 @@ const {
|
||||
.dashboard-tag-section-header .tag-selector__edit,
|
||||
.dashboard-tag-section-header .tag-selector__delete {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
|
||||
@@ -6,8 +6,9 @@ Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TagSelector } from '@/components/ui/tag-selector';
|
||||
import { TagOverflowList, TagSelector } from '@/components/ui/tag-selector';
|
||||
import type { ItemTag, TaggedItem } from '@/types/user-stats';
|
||||
import { getTagsByIdsInListOrder } from '@/utils/item-tag-order';
|
||||
|
||||
const props = defineProps<{
|
||||
item: TaggedItem;
|
||||
@@ -22,20 +23,13 @@ const emit = defineEmits<{
|
||||
'create-tag': [name: string];
|
||||
'rename-tag': [tagId: string, name: string];
|
||||
'update-tag-color': [tagId: string, color: string];
|
||||
'reorder-tags': [tags: ItemTag[]];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedTags = computed(() => {
|
||||
return props.item.tagIds
|
||||
.map(tagId => props.tags.find(tag => tag.id === tagId))
|
||||
.filter((tag): tag is ItemTag => tag !== undefined);
|
||||
});
|
||||
|
||||
const visibleTagBadges = computed(() => selectedTags.value.slice(0, 1));
|
||||
|
||||
const hiddenTagCount = computed(() => {
|
||||
return Math.max(0, selectedTags.value.length - visibleTagBadges.value.length);
|
||||
return getTagsByIdsInListOrder(props.tags, props.item.tagIds);
|
||||
});
|
||||
|
||||
const tagSummary = computed(() => {
|
||||
@@ -63,21 +57,11 @@ function handleOpenChange(open: boolean) {
|
||||
:title="tagSummary"
|
||||
@click.stop="emit('open')"
|
||||
>
|
||||
<template v-if="visibleTagBadges.length > 0">
|
||||
<span
|
||||
v-for="tag in visibleTagBadges"
|
||||
:key="tag.id"
|
||||
class="tag-selector__badge entry-card-tags__badge"
|
||||
:style="{ backgroundColor: `${tag.color}25`, color: tag.color }"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hiddenTagCount > 0"
|
||||
class="tag-selector__badge tag-selector__badge--more entry-card-tags__badge"
|
||||
>
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
<template v-if="selectedTags.length > 0">
|
||||
<TagOverflowList
|
||||
:tags="selectedTags"
|
||||
badge-class="entry-card-tags__badge"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
@@ -96,7 +80,6 @@ function handleOpenChange(open: boolean) {
|
||||
:tags="tags"
|
||||
:selected-tag-ids="item.tagIds"
|
||||
:allow-create="true"
|
||||
:max-badges="1"
|
||||
:full-width="true"
|
||||
:open-on-mount="true"
|
||||
trigger-variant="default"
|
||||
@@ -106,6 +89,7 @@ function handleOpenChange(open: boolean) {
|
||||
@create-tag="name => emit('create-tag', name)"
|
||||
@rename-tag="(tagId, name) => emit('rename-tag', tagId, name)"
|
||||
@update-tag-color="(tagId, color) => emit('update-tag-color', tagId, color)"
|
||||
@reorder-tags="nextTags => emit('reorder-tags', nextTags)"
|
||||
@open-change="handleOpenChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -168,14 +152,11 @@ function handleOpenChange(open: boolean) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-card-tags .tag-selector__selected-tags {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.entry-card-tags__static {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
@@ -204,6 +204,10 @@ async function handleRenameTag(tagId: string, name: string) {
|
||||
async function handleUpdateTagColor(tagId: string, color: string) {
|
||||
await userStatsStore.updateTagColor(tagId, color);
|
||||
}
|
||||
|
||||
async function handleReorderTags(nextTags: ItemTag[]) {
|
||||
await userStatsStore.setTags(nextTags);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -370,6 +374,7 @@ async function handleUpdateTagColor(tagId: string, color: string) {
|
||||
@create-tag="(name) => handleCreateTagForItem(item, name)"
|
||||
@rename-tag="handleRenameTag"
|
||||
@update-tag-color="handleUpdateTagColor"
|
||||
@reorder-tags="handleReorderTags"
|
||||
/>
|
||||
</template>
|
||||
</EntryCard>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { computed } from 'vue';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import { useUserStatsStore } from '@/stores/storage/user-stats';
|
||||
import type { ItemTag } from '@/types/user-stats';
|
||||
import { getTagsByIdsInListOrder } from '@/utils/item-tag-order';
|
||||
|
||||
const tagColors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#8b5cf6', '#ec4899'];
|
||||
|
||||
@@ -15,18 +16,13 @@ export function useFileBrowserTags() {
|
||||
const tagIdsByPath = computed(() => {
|
||||
return new Map(userStatsStore.taggedItems.map(item => [item.path, item.tagIds]));
|
||||
});
|
||||
const tagsById = computed(() => {
|
||||
return new Map(userStatsStore.tags.map(tag => [tag.id, tag]));
|
||||
});
|
||||
|
||||
function getEntryTagIds(entry: DirEntry): string[] {
|
||||
return tagIdsByPath.value.get(entry.path) ?? [];
|
||||
}
|
||||
|
||||
function getEntryTags(entry: DirEntry): ItemTag[] {
|
||||
return getEntryTagIds(entry)
|
||||
.map(tagId => tagsById.value.get(tagId))
|
||||
.filter((tag): tag is ItemTag => !!tag);
|
||||
return getTagsByIdsInListOrder(availableTags.value, getEntryTagIds(entry));
|
||||
}
|
||||
|
||||
function getEntriesSharedTagIds(entries: DirEntry[]): string[] {
|
||||
@@ -78,5 +74,6 @@ export function useFileBrowserTags() {
|
||||
createTagForEntries,
|
||||
renameTag: userStatsStore.renameTag,
|
||||
updateTagColor: userStatsStore.updateTagColor,
|
||||
setTags: userStatsStore.setTags,
|
||||
};
|
||||
}
|
||||
@@ -116,6 +116,7 @@ const {
|
||||
createTagForEntries,
|
||||
renameTag,
|
||||
updateTagColor,
|
||||
setTags,
|
||||
} = useFileBrowserTags();
|
||||
|
||||
async function handleToggleTag(tagId: string) {
|
||||
@@ -456,7 +457,6 @@ function handleCreateLink(linkKind: LinkCreationKind) {
|
||||
:tags="availableTags"
|
||||
:selected-tag-ids="selectedItemTagIds"
|
||||
:allow-create="true"
|
||||
:max-badges="1"
|
||||
:full-width="true"
|
||||
trigger-variant="default"
|
||||
align="end"
|
||||
@@ -467,6 +467,7 @@ function handleCreateLink(linkKind: LinkCreationKind) {
|
||||
@create-tag="handleCreateTag"
|
||||
@rename-tag="handleRenameTag"
|
||||
@update-tag-color="handleUpdateTagColor"
|
||||
@reorder-tags="setTags"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+4
-17
@@ -9,7 +9,7 @@ import { LoaderCircleIcon } from '@lucide/vue';
|
||||
import type { ListReorderableColumnId } from '@/types/user-settings';
|
||||
import type { ItemTag } from '@/types/user-stats';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TagSelector } from '@/components/ui/tag-selector';
|
||||
import { TagOverflowList, TagSelector } from '@/components/ui/tag-selector';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
|
||||
interface FileBrowserListDateDisplay {
|
||||
@@ -34,7 +34,6 @@ interface FileBrowserListViewColumnRow {
|
||||
createdDate: FileBrowserListDateDisplay;
|
||||
selectedTagIds: string[];
|
||||
tagBadges: FileBrowserListTagBadge[];
|
||||
hiddenTagCount: number;
|
||||
tagSummary: string;
|
||||
isTagSelectorMounted: boolean;
|
||||
kindLabel: string;
|
||||
@@ -54,6 +53,7 @@ const emit = defineEmits<{
|
||||
createTag: [entry: DirEntry, name: string];
|
||||
renameTag: [tagId: string, name: string];
|
||||
updateTagColor: [tagId: string, color: string];
|
||||
reorderTags: [tags: ItemTag[]];
|
||||
tagsOpenChange: [entryPath: string, open: boolean];
|
||||
openTagSelector: [entryPath: string];
|
||||
}>();
|
||||
@@ -129,7 +129,6 @@ const tagsColumnInteractionAttrs = computed(() => {
|
||||
:tags="props.availableTags"
|
||||
:selected-tag-ids="props.row.selectedTagIds"
|
||||
:allow-create="true"
|
||||
:max-badges="1"
|
||||
:full-width="true"
|
||||
:open-on-mount="true"
|
||||
trigger-variant="default"
|
||||
@@ -139,6 +138,7 @@ const tagsColumnInteractionAttrs = computed(() => {
|
||||
@create-tag="name => emit('createTag', props.row.entry, name)"
|
||||
@rename-tag="(tagId, name) => emit('renameTag', tagId, name)"
|
||||
@update-tag-color="(tagId, color) => emit('updateTagColor', tagId, color)"
|
||||
@reorder-tags="nextTags => emit('reorderTags', nextTags)"
|
||||
@open-change="open => emit('tagsOpenChange', props.row.entry.path, open)"
|
||||
/>
|
||||
<button
|
||||
@@ -149,20 +149,7 @@ const tagsColumnInteractionAttrs = computed(() => {
|
||||
@click="emit('openTagSelector', props.row.entry.path)"
|
||||
>
|
||||
<template v-if="props.row.tagBadges.length > 0">
|
||||
<span
|
||||
v-for="tag in props.row.tagBadges"
|
||||
:key="tag.id"
|
||||
class="tag-selector__badge"
|
||||
:style="tag.style"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="props.row.hiddenTagCount > 0"
|
||||
class="tag-selector__badge tag-selector__badge--more"
|
||||
>
|
||||
+{{ props.row.hiddenTagCount }}
|
||||
</span>
|
||||
<TagOverflowList :tags="props.row.tagBadges" />
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
|
||||
@@ -72,7 +72,6 @@ interface FileBrowserListDisplayRow extends FileBrowserListVirtualRow {
|
||||
createdDate: FileBrowserListDateDisplay;
|
||||
selectedTagIds: string[];
|
||||
tagBadges: FileBrowserListTagBadge[];
|
||||
hiddenTagCount: number;
|
||||
tagSummary: string;
|
||||
isTagSelectorMounted: boolean;
|
||||
kindLabel: string;
|
||||
@@ -158,6 +157,7 @@ const {
|
||||
createTagForEntries,
|
||||
renameTag,
|
||||
updateTagColor,
|
||||
setTags,
|
||||
} = useFileBrowserTags();
|
||||
|
||||
const clipboardPathsMap = computed(() => {
|
||||
@@ -332,7 +332,7 @@ function createDisplayRow(row: FileBrowserListVirtualRow): FileBrowserListDispla
|
||||
const sizeDisplay = visible.size ? getEntrySizeDisplay(entry) : emptySizeDisplay;
|
||||
const selectedTagIds = visible.tags ? getEntryTagIds(entry) : [];
|
||||
const selectedTags = visible.tags ? getEntryTags(entry) : [];
|
||||
const tagBadges = selectedTags.slice(0, 1).map(createTagBadge);
|
||||
const tagBadges = selectedTags.map(createTagBadge);
|
||||
const entryDescription = ctx.entryDescription?.(entry);
|
||||
const clipboardPathType = clipboardPathsMap.value.get(entry.path) || undefined;
|
||||
const isSelected = ctx.isEntrySelected(entry);
|
||||
@@ -346,7 +346,6 @@ function createDisplayRow(row: FileBrowserListVirtualRow): FileBrowserListDispla
|
||||
const linksDisplay = visible.links ? getDirEntryLinksDisplay(entry) : '';
|
||||
const linkStatusLabel = visible.linkStatus ? getLinkStatusDisplay(entry) : '';
|
||||
const isTagSelectorMounted = visible.tags && activeTagSelectorPath.value === entry.path;
|
||||
const hiddenTagCount = Math.max(0, selectedTagIds.length - tagBadges.length);
|
||||
const tagSummary = visible.tags ? getEntryTagSummary(selectedTags, selectedTagIds) : '';
|
||||
const memoParts = [
|
||||
visibleColumnMemoKey.value,
|
||||
@@ -396,7 +395,6 @@ function createDisplayRow(row: FileBrowserListVirtualRow): FileBrowserListDispla
|
||||
createdDate,
|
||||
selectedTagIds,
|
||||
tagBadges,
|
||||
hiddenTagCount,
|
||||
tagSummary,
|
||||
isTagSelectorMounted,
|
||||
kindLabel,
|
||||
@@ -510,6 +508,7 @@ const visibleRows = computed<FileBrowserListDisplayRow[]>(() => {
|
||||
@create-tag="handleCreateEntryTag"
|
||||
@rename-tag="renameTag"
|
||||
@update-tag-color="updateTagColor"
|
||||
@reorder-tags="setTags"
|
||||
@tags-open-change="handleEntryTagsOpenChange"
|
||||
@open-tag-selector="openEntryTagSelector"
|
||||
/>
|
||||
|
||||
@@ -204,4 +204,50 @@ describe('user stats reorder setters', () => {
|
||||
]);
|
||||
expect(lazyStoreSetMock).toHaveBeenCalledWith('taggedItems', nextTaggedItems);
|
||||
});
|
||||
|
||||
it('saves tags in the UI order', async () => {
|
||||
const tags = [
|
||||
createTag('tag-work', 'Work'),
|
||||
createTag('tag-personal', 'Personal'),
|
||||
createTag('tag-archive', 'Archive'),
|
||||
];
|
||||
const store = useUserStatsStore();
|
||||
await store.init(createStatsBootstrap({
|
||||
...DEFAULT_USER_STATS,
|
||||
tags,
|
||||
}));
|
||||
lazyStoreSetMock.mockClear();
|
||||
lazyStoreSaveMock.mockClear();
|
||||
|
||||
const nextTags = [tags[2], tags[0], tags[1]];
|
||||
await store.setTags(nextTags);
|
||||
|
||||
expect(store.tags.map(tag => tag.id)).toEqual([
|
||||
'tag-archive',
|
||||
'tag-work',
|
||||
'tag-personal',
|
||||
]);
|
||||
expect(lazyStoreSetMock).toHaveBeenCalledWith('tags', nextTags);
|
||||
expect(lazyStoreSaveMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not save tags when membership changes or order is unchanged', async () => {
|
||||
const tags = [
|
||||
createTag('tag-work', 'Work'),
|
||||
createTag('tag-personal', 'Personal'),
|
||||
];
|
||||
const store = useUserStatsStore();
|
||||
await store.init(createStatsBootstrap({
|
||||
...DEFAULT_USER_STATS,
|
||||
tags,
|
||||
}));
|
||||
lazyStoreSetMock.mockClear();
|
||||
lazyStoreSaveMock.mockClear();
|
||||
|
||||
await store.setTags([createTag('tag-work', 'Work'), createTag('tag-other', 'Other')]);
|
||||
await store.setTags(tags);
|
||||
|
||||
expect(store.tags.map(tag => tag.id)).toEqual(['tag-work', 'tag-personal']);
|
||||
expect(lazyStoreSetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type StartupStorageFileBootstrap,
|
||||
} from './utils/startup-storage-bootstrap';
|
||||
import { i18n } from '@/localization';
|
||||
import { getTagsByIdsInListOrder } from '@/utils/item-tag-order';
|
||||
import { reconcileMissingTagDefinitions as mergeTagDefinitionsFromTaggedItems } from '@/utils/reconcile-user-stats-tags';
|
||||
import { haveSameKeyOrder, haveSameKeys } from '@/utils/reorder-matching-items';
|
||||
import { isVirtualLocationPath } from '@/utils/virtual-path-constants';
|
||||
@@ -205,7 +206,7 @@ export const useUserStatsStore = defineStore('userStats', () => {
|
||||
|
||||
if (!taggedItem) return [];
|
||||
|
||||
return userStats.value.tags.filter(tag => taggedItem.tagIds.includes(tag.id));
|
||||
return getTagsByIdsInListOrder(userStats.value.tags, taggedItem.tagIds);
|
||||
}
|
||||
|
||||
async function addTagToItem(path: string, tagId: string, isFile = false) {
|
||||
@@ -340,6 +341,17 @@ export const useUserStatsStore = defineStore('userStats', () => {
|
||||
);
|
||||
}
|
||||
|
||||
async function setTags(nextTags: ItemTag[]) {
|
||||
await replaceListIfMembershipMatches(
|
||||
userStats.value.tags,
|
||||
nextTags,
|
||||
tag => tag.id,
|
||||
(items) => {
|
||||
userStats.value.tags = items;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function updateTagColor(tagId: string, color: string) {
|
||||
const tag = userStats.value.tags.find(tagItem => tagItem.id === tagId);
|
||||
|
||||
@@ -625,6 +637,7 @@ export const useUserStatsStore = defineStore('userStats', () => {
|
||||
renameTag,
|
||||
setFavorites,
|
||||
setTaggedItems,
|
||||
setTags,
|
||||
updateTagColor,
|
||||
recordItemOpen,
|
||||
clearHistory,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getTagsByIdsInListOrder } from '@/utils/item-tag-order';
|
||||
import type { ItemTag } from '@/types/user-stats';
|
||||
|
||||
function createTag(id: string): ItemTag {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
color: '#000000',
|
||||
};
|
||||
}
|
||||
|
||||
describe('getTagsByIdsInListOrder', () => {
|
||||
it('returns selected tags in the stored tags list order', () => {
|
||||
const tags = [createTag('work'), createTag('personal'), createTag('archive')];
|
||||
|
||||
expect(getTagsByIdsInListOrder(tags, ['archive', 'work']).map(tag => tag.id)).toEqual([
|
||||
'work',
|
||||
'archive',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips unknown selected ids', () => {
|
||||
const tags = [createTag('work')];
|
||||
|
||||
expect(getTagsByIdsInListOrder(tags, ['missing', 'work']).map(tag => tag.id)).toEqual([
|
||||
'work',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import type { ItemTag } from '@/types/user-stats';
|
||||
|
||||
export function getTagsByIdsInListOrder(
|
||||
tags: ItemTag[],
|
||||
tagIds: readonly string[],
|
||||
): ItemTag[] {
|
||||
const selectedIds = new Set(tagIds);
|
||||
|
||||
return tags.filter(tag => selectedIds.has(tag.id));
|
||||
}
|
||||
Reference in new issue
Block a user