diff --git a/src/components/sortable-list/sortable-list.vue b/src/components/sortable-list/sortable-list.vue index 291fdad5..e60f2663 100644 --- a/src/components/sortable-list/sortable-list.vue +++ b/src/components/sortable-list/sortable-list.vue @@ -292,7 +292,7 @@ onUnmounted(() => { opacity: 0; } -.sortable-list-move { +.sortable-list--dragging .sortable-list-move { transition: transform 250ms ease; } diff --git a/src/components/ui/tag-selector/__tests__/tag-overflow-list.test.ts b/src/components/ui/tag-selector/__tests__/tag-overflow-list.test.ts new file mode 100644 index 00000000..f3dfeafc --- /dev/null +++ b/src/components/ui/tag-selector/__tests__/tag-overflow-list.test.ts @@ -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, + }); + }); +}); diff --git a/src/components/ui/tag-selector/index.ts b/src/components/ui/tag-selector/index.ts index b894774a..6e5982c4 100644 --- a/src/components/ui/tag-selector/index.ts +++ b/src/components/ui/tag-selector/index.ts @@ -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'; diff --git a/src/components/ui/tag-selector/tag-overflow-list.ts b/src/components/ui/tag-selector/tag-overflow-list.ts new file mode 100644 index 00000000..a78fb30a --- /dev/null +++ b/src/components/ui/tag-selector/tag-overflow-list.ts @@ -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; +} + +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, + }; +} diff --git a/src/components/ui/tag-selector/tag-overflow-list.vue b/src/components/ui/tag-selector/tag-overflow-list.vue new file mode 100644 index 00000000..6d1aaa52 --- /dev/null +++ b/src/components/ui/tag-selector/tag-overflow-list.vue @@ -0,0 +1,186 @@ + + + + + + + diff --git a/src/components/ui/tag-selector/tag-selector.vue b/src/components/ui/tag-selector/tag-selector.vue index c9a5a669..bc0f34e6 100644 --- a/src/components/ui/tag-selector/tag-selector.vue +++ b/src/components/ui/tag-selector/tag-selector.vue @@ -6,7 +6,13 @@ Copyright © 2021 - present Aleksey Hoffman. All rights reserved. diff --git a/src/modules/navigator/components/file-browser/composables/use-file-browser-tags.ts b/src/modules/navigator/components/file-browser/composables/use-file-browser-tags.ts index d8204597..929b621d 100644 --- a/src/modules/navigator/components/file-browser/composables/use-file-browser-tags.ts +++ b/src/modules/navigator/components/file-browser/composables/use-file-browser-tags.ts @@ -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, }; } diff --git a/src/modules/navigator/components/file-browser/file-browser-actions-menu.vue b/src/modules/navigator/components/file-browser/file-browser-actions-menu.vue index 0ee3aca5..5b47828c 100644 --- a/src/modules/navigator/components/file-browser/file-browser-actions-menu.vue +++ b/src/modules/navigator/components/file-browser/file-browser-actions-menu.vue @@ -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" /> diff --git a/src/modules/navigator/components/file-browser/file-browser-list-view-column-cell.vue b/src/modules/navigator/components/file-browser/file-browser-list-view-column-cell.vue index b1ba0e96..9e52e366 100644 --- a/src/modules/navigator/components/file-browser/file-browser-list-view-column-cell.vue +++ b/src/modules/navigator/components/file-browser/file-browser-list-view-column-cell.vue @@ -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)" />