jmou/yakatak
git clone https://github.com/jmou/yakatak.git
git clone git@github.com:jmou/yakatak.git
Loading…
(top)/ui/src/components/CardCarousel.vue
language: Vue
4.92 KB / 207 lines / 182 loc
History
View raw
<script setup lang="ts">
import { useElementSize } from "@vueuse/core";
import { useTemplateRef, watch, watchEffect } from "vue";
import type { Card } from "../lib/common.ts";

const { cards, picked } = defineProps<{
  cards: Card[];
  picked: number;
}>();

const emit = defineEmits<{
  pick: [cardIndex: number];
  autoScroll: [];
}>();

const carouselElem = useTemplateRef("carouselElem");

const { width } = useElementSize(carouselElem);

watchEffect(
  () => {
    const elem = cards[picked]?.elem;
    if (elem) {
      // Typically scrollIntoView() affects all containing overflows. This
      // throws off vertical scrolling in the piles pane, so we resync on the
      // autoScroll event.
      //
      // The {container: "nearest"} option would change this to only affect
      // the carousel; we shouldn't need the autoScroll event then. As of
      // late 2025 it is just starting to see support in Chrome, but not yet
      // Firefox nor Safari.
      elem.scrollIntoView({ block: "nearest" });
      emit("autoScroll");
    }
  },
  { flush: "post" },
);

// Keep the picked card in view if we are resized smaller. Unlike the
// scrolling to a newly picked card above, this is not a direct user action.
watch(width, (_, prevWidth) => {
  // TODO we shouldn't scroll if the card was outside the scrollport
  if (width.value < prevWidth) {
    cards[picked]?.elem?.scrollIntoView({ block: "nearest", behavior: "instant" });
  }
});
</script>

<template>
  <div class="carousel" ref="carouselElem">
    <a
      v-for="(card, cardIndex) in cards"
      :key="card.id"
      :href="card.url"
      class="card"
      :class="{ picked: cardIndex === picked }"
      :style="{ viewTransitionName: `card-${card.id.replace('.', '_')}` }"
      @click.exact.prevent="$emit('pick', cardIndex)"
      v-on="{ focus: $attrs.onFocus }"
      :ref="(elem) => (card.elem = elem as Element)"
    >
      <img :src="`/api/scrapes/${card.id}/thumb`" :alt="card.title" :title="card.title" />
    </a>
  </div>
</template>

<style scoped>
.carousel {
  display: flex;
  overflow: auto;
  padding: 4px 0 2px;
  gap: 3px;

  --gutter-width: 5px;
  --scroll-shadow-width: 20px;
  scroll-behavior: smooth;
  scroll-padding: calc(var(--gutter-width) + 5px);
  scrollbar-width: none;
  &::before,
  &::after {
    content: "";
    display: block;
    flex: none;
    position: sticky;
    width: var(--scroll-shadow-width);
    z-index: 2;
    pointer-events: none;

    animation-name: reveal, detect-scroll;
    animation-timeline: scroll(x);
    animation-fill-mode: both;

    --visibility-if-can-scroll: var(--can-scroll) visible;
    --visibility-if-cant-scroll: hidden;
    visibility: var(--visibility-if-can-scroll, var(--visibility-if-cant-scroll));
  }
  &::before {
    left: 0;
    margin-right: calc(-1 * var(--scroll-shadow-width));
    background: radial-gradient(farthest-side at 0% 50%, #0005, #0003 50%, transparent);
    animation-range: var(--gutter-width) 30px;
  }
  &::after {
    right: 0;
    margin-left: calc(-1 * var(--scroll-shadow-width));
    background: radial-gradient(farthest-side at 100% 50%, #0005, #0003 50%, transparent);
    animation-direction: reverse;
    animation-range: calc(100% - 30px) calc(100% - var(--gutter-width));
  }
}

.card {
  display: block;
  flex: none;
  aspect-ratio: 5 / 7;
  border: 1px solid #ddd;
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;

  transition: transform 0.15s ease;
  view-transition-class: card;

  &.picked {
    border-color: #666;
  }

  &:first-child {
    margin-left: var(--gutter-width);
  }
  &:last-child {
    margin-right: var(--gutter-width);
  }

  &:hover {
    transform: translateY(-2px);
  }

  img {
    /* Aim for ~20% of original scale. */
    height: 150px;
    /* Constrain to at least 2/3 of the original viewport height. */
    min-height: 100%;
    max-height: 150%;
  }
}

/* If this is a single unnested selector, we lose the non-global portion;
   the nested selectors work as expected. */
:global(.selected) {
  .card.picked {
    border-color: #38f;
    outline: 3px solid #acf;
    /* This is not normally significant, but it paints view transitions above
       other cards. */
    z-index: 1;
  }
}

@keyframes reveal {
  from {
    opacity: 0;
  }
}

/* https://brm.us/css-can-scroll */
@keyframes detect-scroll {
  from,
  to {
    --can-scroll: ;
  }
}
</style>

<style>
::view-transition-group(root) {
  animation-duration: 0s;
}

/* Workaround to prevent cards from painting over the details pane; not an
   actual transition. */
::view-transition-group(details) {
  animation-duration: 0s;
  z-index: 1;
}

@keyframes slide-up {
  to {
    opacity: 0;
    transform: translateY(-20%);
  }
}

::view-transition-old(.card):only-child,
::view-transition-new(.card):only-child {
  animation-name: slide-up;
}
::view-transition-new(.card):only-child {
  animation-direction: reverse;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(.card) {
    animation-duration: 0s;
  }
}
</style>