Skip to content

Commit adbcdc6

Browse files
committed
Cleanup tag display for long lists of tags
Signed-off-by: Olga Bulat <obulat@gmail.com>
1 parent 4ffa14b commit adbcdc6

File tree

5 files changed

+466
-19
lines changed

5 files changed

+466
-19
lines changed
Lines changed: 181 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,214 @@
11
<template>
2-
<ul v-if="tags.length && additionalSearchViews" class="flex flex-wrap gap-3">
3-
<VTag
4-
v-for="(tag, index) in tags"
5-
:key="index"
6-
:href="localizedTagPath(tag)"
7-
:title="tag.name"
8-
/>
9-
</ul>
10-
<ul v-else class="flex flex-wrap gap-2">
11-
<VMediaTag v-for="(tag, index) in tags" :key="index" tag="li">{{
12-
tag.name
2+
<div v-if="normalizedTags.length && additionalSearchViews">
3+
<ul
4+
ref="tagsContainerRef"
5+
:aria-label="$t('mediaDetails.tags.title').toString()"
6+
class="flex flex-wrap gap-3 overflow-y-hidden p-1.5px"
7+
:class="heightClass"
8+
>
9+
<li v-for="tag in visibleTags" :key="tag">
10+
<VTag :href="localizedTagPath(tag)" :title="tag" />
11+
</li>
12+
</ul>
13+
<VButton
14+
v-if="hasOverflow"
15+
size="small"
16+
variant="transparent-tx"
17+
has-icon-end
18+
class="label-bold -ms-2 mt-4 hover:underline"
19+
@click="handleClick"
20+
>{{
21+
$t(
22+
buttonStatus === "show"
23+
? "mediaDetails.tags.showAll"
24+
: "mediaDetails.tags.seeLess"
25+
)
26+
}}<VIcon
27+
name="caret-down"
28+
:size="4"
29+
:class="{ '-scale-y-100 transform': buttonStatus === 'hide' }"
30+
/></VButton>
31+
</div>
32+
33+
<ul
34+
v-else
35+
class="flex flex-wrap gap-2"
36+
:aria-label="$t('mediaDetails.tags').toString()"
37+
>
38+
<VMediaTag v-for="(tag, index) in normalizedTags" :key="index" tag="li">{{
39+
tag
1340
}}</VMediaTag>
1441
</ul>
1542
</template>
1643
<script lang="ts">
17-
import { computed, defineComponent, PropType } from "vue"
44+
import {
45+
computed,
46+
defineComponent,
47+
nextTick,
48+
onMounted,
49+
type PropType,
50+
ref,
51+
} from "vue"
1852
import { useContext } from "@nuxtjs/composition-api"
1953
54+
import { useResizeObserver, watchDebounced } from "@vueuse/core"
55+
2056
import type { Tag } from "~/types/media"
2157
import { useFeatureFlagStore } from "~/stores/feature-flag"
2258
59+
import { focusElement } from "~/utils/focus-management"
60+
2361
import VMediaTag from "~/components/VMediaTag/VMediaTag.vue"
2462
import VTag from "~/components/VTag/VTag.vue"
63+
import VButton from "~/components/VButton.vue"
64+
import VIcon from "~/components/VIcon/VIcon.vue"
2565
2666
export default defineComponent({
2767
name: "VMediaTags",
28-
components: { VMediaTag, VTag },
68+
components: { VIcon, VButton, VMediaTag, VTag },
2969
props: {
3070
tags: {
3171
type: Array as PropType<Tag[]>,
3272
required: true,
3373
},
3474
},
35-
setup() {
75+
setup(props) {
76+
const tagsContainerRef = ref<HTMLElement>()
77+
3678
const { app } = useContext()
3779
const featureFlagStore = useFeatureFlagStore()
3880
3981
const additionalSearchViews = computed(() =>
4082
featureFlagStore.isOn("additional_search_views")
4183
)
4284
43-
const localizedTagPath = (tag: Tag) => {
44-
return app.localePath({ path: `tag/${tag.name}` })
85+
const localizedTagPath = (tag: string) => {
86+
return app.localePath({ path: `tag/${tag}` })
87+
}
88+
89+
const normalizedTags = computed(() => {
90+
return Array.from(new Set(props.tags.map((tag) => tag.name)))
91+
})
92+
93+
const fourthRowStartsAt = ref<number>()
94+
95+
function findFourthRowStartsAt(parent: HTMLElement) {
96+
const children = Array.from(parent.children)
97+
if (!children.length) {
98+
return 0
99+
}
100+
let rowCount = 0
101+
for (let i = 0; i < children.length; i++) {
102+
const child = children[i] as HTMLElement
103+
if (
104+
!child.previousElementSibling ||
105+
(child.previousElementSibling as HTMLElement).offsetLeft >
106+
child.offsetLeft
107+
) {
108+
rowCount++
109+
}
110+
if (rowCount === 4) {
111+
return i
112+
}
113+
}
114+
return children.length
115+
}
116+
117+
const visibleTags = computed(() => {
118+
return fourthRowStartsAt.value && buttonStatus.value === "show"
119+
? normalizedTags.value.slice(0, fourthRowStartsAt.value)
120+
: normalizedTags.value
121+
})
122+
123+
const hasOverflow = computed(() => {
124+
return (
125+
fourthRowStartsAt.value &&
126+
fourthRowStartsAt.value < normalizedTags.value.length
127+
)
128+
})
129+
130+
onMounted(() => {
131+
/**
132+
* Find the index of the first item after the third row of tags. This is used
133+
* to determine which tags to hide.
134+
*/
135+
if (tagsContainerRef.value) {
136+
fourthRowStartsAt.value = findFourthRowStartsAt(tagsContainerRef.value)
137+
}
138+
})
139+
140+
const buttonStatus = ref<"show" | "hide">("show")
141+
/**
142+
* Toggles the text for the "Show more" button. When showing more tags, we also
143+
* focus the first tag in the newly-opened row for a11y.
144+
*/
145+
const handleClick = () => {
146+
buttonStatus.value = buttonStatus.value === "show" ? "hide" : "show"
147+
if (buttonStatus.value === "hide" && fourthRowStartsAt.value) {
148+
nextTick(() => {
149+
if (!fourthRowStartsAt.value) {
150+
return
151+
}
152+
const firstTagInFourthRow = tagsContainerRef.value?.children.item(
153+
fourthRowStartsAt.value
154+
) as HTMLElement
155+
focusElement(firstTagInFourthRow?.querySelector("a"))
156+
})
157+
}
45158
}
46159
47-
return { additionalSearchViews, localizedTagPath }
160+
const heightClass = computed(() => {
161+
if (!hasOverflow.value) {
162+
return "max-h-none"
163+
}
164+
/**
165+
* Height is 3 rows of tags, gaps, and a padding for the focus rings.
166+
*/
167+
return buttonStatus.value === "show" ? "max-h-[7.6875rem]" : "mah-h-none"
168+
})
169+
170+
const listWidth = ref<number>()
171+
useResizeObserver(tagsContainerRef, (entries) => {
172+
listWidth.value = entries[0].contentRect.width
173+
})
174+
175+
watchDebounced(
176+
listWidth,
177+
(newWidth, oldWidth) => {
178+
if (!tagsContainerRef.value) {
179+
return
180+
}
181+
const isWidening = oldWidth && newWidth && newWidth > oldWidth
182+
183+
if (isWidening) {
184+
fourthRowStartsAt.value = normalizedTags.value.length
185+
}
186+
nextTick(() => {
187+
if (tagsContainerRef.value) {
188+
fourthRowStartsAt.value = findFourthRowStartsAt(
189+
tagsContainerRef.value
190+
)
191+
}
192+
})
193+
},
194+
{ debounce: 300 }
195+
)
196+
197+
return {
198+
tagsContainerRef,
199+
200+
additionalSearchViews,
201+
localizedTagPath,
202+
203+
normalizedTags,
204+
visibleTags,
205+
206+
hasOverflow,
207+
buttonStatus,
208+
heightClass,
209+
210+
handleClick,
211+
}
48212
},
49213
})
50214
</script>

frontend/src/locales/scripts/en.json5

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@
498498
},
499499
imageInfo: "Image information",
500500
audioInfo: "Audio information",
501+
tags: {
502+
title: "Tags",
503+
showAll: "Show all tags",
504+
seeLess: "See less",
505+
},
501506
contentReport: {
502507
short: "Report",
503508
long: "Report this content",

frontend/src/utils/focus-management.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,11 @@ export function isFocusableElement(element: HTMLElement) {
8888
return element.matches(focusableSelector)
8989
}
9090

91-
export function focusElement(element: HTMLElement | null) {
92-
element?.focus()
91+
export function focusElement(element: HTMLElement | Element | null) {
92+
if (!element || !(element instanceof HTMLElement)) {
93+
return
94+
}
95+
element.focus()
9396
}
9497

9598
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select

frontend/test/playwright/e2e/collections.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getH1,
77
getLoadMoreButton,
88
} from "~~/test/playwright/utils/components"
9+
import { t } from "~~/test/playwright/utils/i18n"
910

1011
test.describe.configure({ mode: "parallel" })
1112

@@ -55,3 +56,20 @@ test.describe("collections", () => {
5556
expect(await page.locator("figure").count()).toEqual(20)
5657
})
5758
})
59+
test("some tags are hidden if there are more than 3 rows", async ({ page }) => {
60+
await preparePageForTests(page, "xl", {
61+
features: { additional_search_views: "on" },
62+
})
63+
await page.goto("/image/2bc7dde0-5aad-4cf7-b91d-7f0e3bd06750")
64+
65+
const tags = page.getByRole("list", { name: t("mediaDetails.tags.title") })
66+
await expect(tags).toBeVisible()
67+
const tagsCount = await tags.locator("li").count()
68+
const showMoreButton = page.getByRole("button", {
69+
name: t("mediaDetails.tags.showAll"),
70+
})
71+
await expect(showMoreButton).toBeVisible()
72+
73+
await showMoreButton.click()
74+
expect(await tags.locator("li").count()).toBeGreaterThan(tagsCount)
75+
})

0 commit comments

Comments
 (0)