|
1 | 1 | <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 |
13 | 40 | }}</VMediaTag>
|
14 | 41 | </ul>
|
15 | 42 | </template>
|
16 | 43 | <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" |
18 | 52 | import { useContext } from "@nuxtjs/composition-api"
|
19 | 53 |
|
| 54 | +import { useResizeObserver, watchDebounced } from "@vueuse/core" |
| 55 | +
|
20 | 56 | import type { Tag } from "~/types/media"
|
21 | 57 | import { useFeatureFlagStore } from "~/stores/feature-flag"
|
22 | 58 |
|
| 59 | +import { focusElement } from "~/utils/focus-management" |
| 60 | +
|
23 | 61 | import VMediaTag from "~/components/VMediaTag/VMediaTag.vue"
|
24 | 62 | import VTag from "~/components/VTag/VTag.vue"
|
| 63 | +import VButton from "~/components/VButton.vue" |
| 64 | +import VIcon from "~/components/VIcon/VIcon.vue" |
25 | 65 |
|
26 | 66 | export default defineComponent({
|
27 | 67 | name: "VMediaTags",
|
28 |
| - components: { VMediaTag, VTag }, |
| 68 | + components: { VIcon, VButton, VMediaTag, VTag }, |
29 | 69 | props: {
|
30 | 70 | tags: {
|
31 | 71 | type: Array as PropType<Tag[]>,
|
32 | 72 | required: true,
|
33 | 73 | },
|
34 | 74 | },
|
35 |
| - setup() { |
| 75 | + setup(props) { |
| 76 | + const tagsContainerRef = ref<HTMLElement>() |
| 77 | +
|
36 | 78 | const { app } = useContext()
|
37 | 79 | const featureFlagStore = useFeatureFlagStore()
|
38 | 80 |
|
39 | 81 | const additionalSearchViews = computed(() =>
|
40 | 82 | featureFlagStore.isOn("additional_search_views")
|
41 | 83 | )
|
42 | 84 |
|
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 | + } |
45 | 158 | }
|
46 | 159 |
|
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 | + } |
48 | 212 | },
|
49 | 213 | })
|
50 | 214 | </script>
|
0 commit comments