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:
aleksey-hoffman committed 2026-08-27 15:46:18 +03:00
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,
});
});
});
+2
View File
@@ -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>
+177 -101
View File
@@ -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);
});
});
+53 -9
View File
@@ -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>
@@ -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();
});
});
+14 -1
View File
@@ -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',
]);
});
});
+14
View File
@@ -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));
}