Skip to content

Commit

Permalink
Cleanup tag display for long lists of tags
Browse files Browse the repository at this point in the history
Signed-off-by: Olga Bulat <[email protected]>
  • Loading branch information
obulat committed Feb 19, 2024
1 parent 55562ea commit f6421a0
Show file tree
Hide file tree
Showing 5 changed files with 466 additions and 19 deletions.
198 changes: 181 additions & 17 deletions frontend/src/components/VMediaInfo/VMediaTags.vue
Original file line number Diff line number Diff line change
@@ -1,50 +1,214 @@
<template>
<ul v-if="tags.length && additionalSearchViews" class="flex flex-wrap gap-3">
<VTag
v-for="(tag, index) in tags"
:key="index"
:href="localizedTagPath(tag)"
:title="tag.name"
/>
</ul>
<ul v-else class="flex flex-wrap gap-2">
<VMediaTag v-for="(tag, index) in tags" :key="index" tag="li">{{
tag.name
<div v-if="normalizedTags.length && additionalSearchViews">
<ul
ref="tagsContainerRef"
:aria-label="$t('mediaDetails.tags.title').toString()"
class="flex flex-wrap gap-3 overflow-y-hidden p-1.5px"
:class="heightClass"
>
<li v-for="tag in visibleTags" :key="tag">
<VTag :href="localizedTagPath(tag)" :title="tag" />
</li>
</ul>
<VButton
v-if="hasOverflow"
size="small"
variant="transparent-tx"
has-icon-end
class="label-bold -ms-2 mt-4 hover:underline"
@click="handleClick"
>{{
$t(
buttonStatus === "show"
? "mediaDetails.tags.showAll"
: "mediaDetails.tags.seeLess"
)
}}<VIcon
name="caret-down"
:size="4"
:class="{ '-scale-y-100 transform': buttonStatus === 'hide' }"
/></VButton>
</div>

<ul
v-else
class="flex flex-wrap gap-2"
:aria-label="$t('mediaDetails.tags').toString()"
>
<VMediaTag v-for="(tag, index) in normalizedTags" :key="index" tag="li">{{
tag
}}</VMediaTag>
</ul>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue"
import {
computed,
defineComponent,
nextTick,
onMounted,
type PropType,
ref,
} from "vue"
import { useContext } from "@nuxtjs/composition-api"
import { useResizeObserver, watchDebounced } from "@vueuse/core"
import type { Tag } from "~/types/media"
import { useFeatureFlagStore } from "~/stores/feature-flag"
import { focusElement } from "~/utils/focus-management"
import VMediaTag from "~/components/VMediaTag/VMediaTag.vue"
import VTag from "~/components/VTag/VTag.vue"
import VButton from "~/components/VButton.vue"
import VIcon from "~/components/VIcon/VIcon.vue"
export default defineComponent({
name: "VMediaTags",
components: { VMediaTag, VTag },
components: { VIcon, VButton, VMediaTag, VTag },
props: {
tags: {
type: Array as PropType<Tag[]>,
required: true,
},
},
setup() {
setup(props) {
const tagsContainerRef = ref<HTMLElement>()
const { app } = useContext()
const featureFlagStore = useFeatureFlagStore()
const additionalSearchViews = computed(() =>
featureFlagStore.isOn("additional_search_views")
)
const localizedTagPath = (tag: Tag) => {
return app.localePath({ path: `tag/${tag.name}` })
const localizedTagPath = (tag: string) => {
return app.localePath({ path: `tag/${tag}` })
}
const normalizedTags = computed(() => {
return Array.from(new Set(props.tags.map((tag) => tag.name)))
})
const fourthRowStartsAt = ref<number>()
function findFourthRowStartsAt(parent: HTMLElement) {
const children = Array.from(parent.children)
if (!children.length) {
return 0
}
let rowCount = 0
for (let i = 0; i < children.length; i++) {
const child = children[i] as HTMLElement
if (
!child.previousElementSibling ||
(child.previousElementSibling as HTMLElement).offsetLeft >
child.offsetLeft
) {
rowCount++
}
if (rowCount === 4) {
return i
}
}
return children.length
}
const visibleTags = computed(() => {
return fourthRowStartsAt.value && buttonStatus.value === "show"
? normalizedTags.value.slice(0, fourthRowStartsAt.value)
: normalizedTags.value
})
const hasOverflow = computed(() => {
return (
fourthRowStartsAt.value &&
fourthRowStartsAt.value < normalizedTags.value.length
)
})
onMounted(() => {
/**
* Find the index of the first item after the third row of tags. This is used
* to determine which tags to hide.
*/
if (tagsContainerRef.value) {
fourthRowStartsAt.value = findFourthRowStartsAt(tagsContainerRef.value)
}
})
const buttonStatus = ref<"show" | "hide">("show")
/**
* Toggles the text for the "Show more" button. When showing more tags, we also
* focus the first tag in the newly-opened row for a11y.
*/
const handleClick = () => {
buttonStatus.value = buttonStatus.value === "show" ? "hide" : "show"
if (buttonStatus.value === "hide" && fourthRowStartsAt.value) {
nextTick(() => {
if (!fourthRowStartsAt.value) {
return
}
const firstTagInFourthRow = tagsContainerRef.value?.children.item(
fourthRowStartsAt.value
) as HTMLElement
focusElement(firstTagInFourthRow?.querySelector("a"))
})
}
}
return { additionalSearchViews, localizedTagPath }
const heightClass = computed(() => {
if (!hasOverflow.value) {
return "max-h-none"
}
/**
* Height is 3 rows of tags, gaps, and a padding for the focus rings.
*/
return buttonStatus.value === "show" ? "max-h-[7.6875rem]" : "mah-h-none"
})
const listWidth = ref<number>()
useResizeObserver(tagsContainerRef, (entries) => {
listWidth.value = entries[0].contentRect.width
})
watchDebounced(
listWidth,
(newWidth, oldWidth) => {
if (!tagsContainerRef.value) {
return
}
const isWidening = oldWidth && newWidth && newWidth > oldWidth
if (isWidening) {
fourthRowStartsAt.value = normalizedTags.value.length
}
nextTick(() => {
if (tagsContainerRef.value) {
fourthRowStartsAt.value = findFourthRowStartsAt(
tagsContainerRef.value
)
}
})
},
{ debounce: 300 }
)
return {
tagsContainerRef,
additionalSearchViews,
localizedTagPath,
normalizedTags,
visibleTags,
hasOverflow,
buttonStatus,
heightClass,
handleClick,
}
},
})
</script>
5 changes: 5 additions & 0 deletions frontend/src/locales/scripts/en.json5
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,11 @@
},
imageInfo: "Image information",
audioInfo: "Audio information",
tags: {
title: "Tags",
showAll: "Show all tags",
seeLess: "See less",
},
contentReport: {
short: "Report",
long: "Report this content",
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/utils/focus-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,11 @@ export function isFocusableElement(element: HTMLElement) {
return element.matches(focusableSelector)
}

export function focusElement(element: HTMLElement | null) {
element?.focus()
export function focusElement(element: HTMLElement | Element | null) {
if (!element || !(element instanceof HTMLElement)) {
return
}
element.focus()
}

// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select
Expand Down
18 changes: 18 additions & 0 deletions frontend/test/playwright/e2e/collections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getH1,
getLoadMoreButton,
} from "~~/test/playwright/utils/components"
import { t } from "~~/test/playwright/utils/i18n"

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

Expand Down Expand Up @@ -55,3 +56,20 @@ test.describe("collections", () => {
expect(await page.locator("figure").count()).toEqual(20)
})
})
test("some tags are hidden if there are more than 3 rows", async ({ page }) => {
await preparePageForTests(page, "xl", {
features: { additional_search_views: "on" },
})
await page.goto("/image/2bc7dde0-5aad-4cf7-b91d-7f0e3bd06750")

const tags = page.getByRole("list", { name: t("mediaDetails.tags.title") })
await expect(tags).toBeVisible()
const tagsCount = await tags.locator("li").count()
const showMoreButton = page.getByRole("button", {
name: t("mediaDetails.tags.showAll"),
})
await expect(showMoreButton).toBeVisible()

await showMoreButton.click()
expect(await tags.locator("li").count()).toBeGreaterThan(tagsCount)
})
Loading

0 comments on commit f6421a0

Please sign in to comment.