1<template>
2 <Transition
3 :css="animated"
4 enter-active-class="motion-safe:transition-[opacity,transform] motion-safe:duration-200 motion-safe:ease-out"
5 enter-from-class="opacity-0 -translate-y-1"
6 leave-active-class="motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-in"
7 leave-to-class="opacity-0"
8 >
9 <!-- the after: fade stands in for the feed's own top fade, which the bar covers -->
10 <button
11 v-if="comments.length"
12 type="button"
13 class="flex items-center gap-2.5 bg-surface-gray-1 px-5 py-2.5 text-left after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-3 after:bg-gradient-to-b after:from-surface-base"
14 :aria-label="__('Go to pinned comment {0}', [current + 1])"
15 @click="select"
16 >
17 <!-- one full-height line split evenly per pin; past two pins -my-0.5
18 reaches into the padding so the segments stay tall enough to read -->
19 <span
20 class="relative flex flex-col gap-0.5 self-stretch"
21 :class="{ '-my-0.5': comments.length > 2 }"
22 >
23 <span
24 v-for="comment in comments"
25 :key="comment.name"
26 class="w-[3px] flex-1 rounded-full bg-surface-gray-4"
27 />
28 <span
29 class="absolute inset-x-0 top-0 rounded-full bg-surface-violet-6 motion-safe:transition-transform motion-safe:duration-150 motion-safe:ease-out"
30 :style="indicatorStyle"
31 />
32 </span>
33 <!-- -top-px optically centres Inter, whose glyphs sit low -->
34 <span class="relative -top-px flex min-w-0 flex-1 items-center gap-2">
35 <PinIcon class="size-4 shrink-0 text-ink-gray-8" />
36 <!-- one grid cell, so the leaving and entering previews overlap while they slide -->
37 <span class="grid min-w-0 flex-1 overflow-hidden">
38 <Transition
39 enter-active-class="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
40 leave-active-class="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
41 :enter-from-class="`opacity-0 ${
42 toOlder ? '-translate-y-full' : 'translate-y-full'
43 }`"
44 :leave-to-class="`opacity-0 ${
45 toOlder ? 'translate-y-full' : '-translate-y-full'
46 }`"
47 >
48 <p
49 :key="current"
50 class="truncate text-p-base text-ink-gray-5 [grid-area:1/1]"
51 >
52 <span class="font-medium tabular-nums text-ink-gray-7"
53 >{{ current + 1 }}:</span
54 >
55 {{ preview }}
56 </p>
57 </Transition>
58 </span>
59 </span>
60 </button>
61 </Transition>
62</template>
63
64<script setup lang="ts">
65import { PinIcon } from "@/components/icons";
66import type { PinnedComment } from "@/composables/useTicket";
67import { htmlToText } from "@/utils";
68import { computed, ref, watch } from "vue";
69
70/** Telegram-style pinned bar, one pin at a time, newest first. A click waits
71 * for `jump` to reach the shown pin, then steps to the next older one. */
72const props = defineProps<{
73 comments: PinnedComment[];
74 jump: (name: string) => Promise<void>;
75}>();
76
77const SEGMENT_GAP = "2px";
78// pause after the jump lands before the bar steps on, so the move reads as a result
79const ADVANCE_DELAY_MS = 300;
80
81const current = ref(props.comments.length - 1);
82let jumping = false;
83// the first pins arrive with the page, and that should not animate in
84const animated = ref(props.comments.length > 0);
85// older pins sit higher in the feed, so their preview slides in from above
86const toOlder = ref(true);
87
88const preview = computed(() =>
89 htmlToText(props.comments[current.value]?.content ?? "")
90 .replace(/\s+/g, " ")
91 .trim()
92);
93
94// the violet piece covers segment `current` of the gray track and glides between them
95const indicatorStyle = computed(() => {
96 const count = props.comments.length;
97 return {
98 height: `calc((100% - ${count - 1} * ${SEGMENT_GAP}) / ${count})`,
99 // a translate percentage is the piece's own height, one segment
100 transform: `translateY(calc(${current.value} * (100% + ${SEGMENT_GAP})))`,
101 };
102});
103
104async function select() {
105 const pin = props.comments[current.value];
106 if (jumping || !pin) return;
107 jumping = true;
108 try {
109 await props.jump(pin.name);
110 await new Promise((resolve) => setTimeout(resolve, ADVANCE_DELAY_MS));
111 toOlder.value = current.value > 0;
112 const count = props.comments.length;
113 current.value = (current.value - 1 + count) % count;
114 } finally {
115 jumping = false;
116 }
117}
118
119watch(
120 () => props.comments.length,
121 (count) => (current.value = count - 1)
122);
123watch(
124 () => props.comments.length,
125 () => (animated.value = true),
126 { once: true, flush: "post" }
127);
128</script>
129