Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display generated tags separately #4291

Merged
merged 10 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions frontend/src/components/VMediaInfo/VCollapsibleTagSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<template>
<div class="-my-1.5px">
<ul
ref="tagsContainerRef"
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)">{{ tag }}</VTag>
</li>
zackkrida marked this conversation as resolved.
Show resolved Hide resolved
</ul>
<VButton
v-if="hasOverflow"
size="small"
variant="transparent-tx"
has-icon-end
class="label-bold -ms-2 mt-4 hover:underline"
:aria-expanded="buttonStatus === 'show' ? 'false' : 'true'"
@click="handleClick"
>{{
$t(
buttonStatus === "show"
? "mediaDetails.tags.showMore"
: "mediaDetails.tags.showLess"
)
}}<VIcon
name="caret-down"
:size="4"
:class="{ '-scale-y-100 transform': buttonStatus === 'hide' }"
/></VButton>
</div>
</template>
<script lang="ts">
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 type { SupportedMediaType } from "~/constants/media"
import { useSearchStore } from "~/stores/search"
import { useI18n } from "~/composables/use-i18n"

import { focusElement } from "~/utils/focus-management"

import VTag from "~/components/VTag/VTag.vue"
import VButton from "~/components/VButton.vue"
import VIcon from "~/components/VIcon/VIcon.vue"

// The number of rows to display before collapsing the tags
const ROWS_TO_DISPLAY = 3

export default defineComponent({
name: "VCollapsibleTagSection",
components: { VIcon, VButton, VTag },
props: {
tags: {
type: Array as PropType<Tag[]>,
required: true,
},
mediaType: {
type: String as PropType<SupportedMediaType>,
required: true,
},
},
setup(props) {
const tagsContainerRef = ref<HTMLElement>()

const searchStore = useSearchStore()
const { $sendCustomEvent } = useContext()
const i18n = useI18n()

const localizedTagPath = (tag: string) => {
return searchStore.getCollectionPath({
type: props.mediaType,
collectionParams: { collection: "tag", tag },
})
}

const normalizedTags = computed(() => {
return Array.from(new Set(props.tags.map((tag) => tag.name)))
})

const collapsibleRowsStartAt = ref<number>()
const dir = computed(() => {
return i18n.localeProperties.dir
})

function isFurtherInline(previous: HTMLElement, current: HTMLElement) {
if (dir.value === "rtl") {
return previous.offsetLeft < current.offsetLeft
}
return previous.offsetLeft > current.offsetLeft
}

function findRowStartsAt(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
const previous = child.previousElementSibling as HTMLElement
if (!previous) {
rowCount++
} else if (isFurtherInline(previous, child)) {
rowCount++
}
if (rowCount === ROWS_TO_DISPLAY + 1) {
return i
}
}
return children.length
}

/**
* Only the first 3 rows of tags are visible by default.
* If we hide the tags using CSS only, they will be tabbable,
* even though they are not visible.
*/
const visibleTags = computed<string[]>(() => {
return collapsibleRowsStartAt.value && buttonStatus.value === "show"
? normalizedTags.value.slice(0, collapsibleRowsStartAt.value)
: normalizedTags.value
})

const hasOverflow = computed(() => {
return (
collapsibleRowsStartAt.value &&
collapsibleRowsStartAt.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) {
collapsibleRowsStartAt.value = findRowStartsAt(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"
$sendCustomEvent("TOGGLE_TAG_EXPANSION", {
toState: buttonStatus.value === "show" ? "collapsed" : "expanded",
})
if (buttonStatus.value === "hide" && collapsibleRowsStartAt.value) {
nextTick(() => {
if (!collapsibleRowsStartAt.value) {
return
}
const firstTagInFourthRow = tagsContainerRef.value?.children.item(
collapsibleRowsStartAt.value
) as HTMLElement
focusElement(firstTagInFourthRow?.querySelector("a"))
})
}
}

const heightClass = computed(() => {
if (!hasOverflow.value) {
return "max-h-none"
}
/**
* Height is 3 rows of tags, gaps, and a padding for the focus rings.
* 3 * 2rem (tags) + 2 * 0.75rem (2 gaps) + 0.1875rem (margin for the focus ring)
*/
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) {
collapsibleRowsStartAt.value = normalizedTags.value.length
}
nextTick(() => {
if (tagsContainerRef.value) {
collapsibleRowsStartAt.value = findRowStartsAt(
tagsContainerRef.value
)
}
})
},
{ debounce: 300 }
)

return {
tagsContainerRef,

localizedTagPath,

normalizedTags,
visibleTags,

hasOverflow,
buttonStatus,
heightClass,

handleClick,
}
},
})
</script>
18 changes: 13 additions & 5 deletions frontend/src/components/VMediaInfo/VMediaDetails.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<section class="flex flex-col gap-y-6">
<section class="flex flex-col gap-y-6 md:gap-y-8">
<header class="flex flex-row items-center justify-between">
<h2 class="heading-6 md:heading-5">
{{ $t(`mediaDetails.${media.frontendMediaType}Info`) }}
Expand All @@ -9,10 +9,18 @@
<div class="flex flex-col items-start gap-6 md:flex-row">
<slot name="thumbnail" />

<div class="flex w-full flex-grow flex-col gap-6">
<p v-if="media.description">{{ media.description }}</p>
<VMediaTags :tags="media.tags" :media-type="media.frontendMediaType" />
<VMetadata v-if="metadata" :metadata="metadata" />
<div class="flex flex-col gap-6 md:gap-8">
<div
class="flex w-full flex-grow flex-col items-start gap-6 md:flex-row"
>
<p v-if="media.description">{{ media.description }}</p>
<VMetadata v-if="metadata" :metadata="metadata" />
</div>
<VMediaTags
:tags="media.tags"
:media-type="media.frontendMediaType"
:provider="media.provider"
/>
</div>
</div>
</section>
Expand Down
Loading