Skip to content

Conversation

@titouanmathis
Copy link
Contributor

@titouanmathis titouanmathis commented Nov 16, 2024

To replace the Slider component with native scroll-snap for touch devices.

πŸ”— Linked issue

No issue.

❓ Type of change

  • πŸ“– Documentation (updates to the documentation, readme or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • πŸ‘Œ Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

πŸ“š Description

This PR adds a new Carousel component which aims to replace the existing Slider component.

It aims to be more minimalist by using scroll-snapping on touch device instead of handling drag and drop in JS.

To-do

  • Fix a bug with the prev/next buttons on Chrome
  • Ensure the Carousel implementation is agnostic enough to be able to create different types of animations (use indexable)
  • Disable CarouselDrag on touch friendly devices

πŸ“ Checklist

  • I have linked an issue or discussion.
  • I have added tests (if possible).
  • I have updated the documentation accordingly.
  • I have updated the changelog.

@github-actions
Copy link

github-actions bot commented Nov 16, 2024

Export Size

@studiometa/ui

Name Size Diff
CarouselBtn 2.49 kB +2.49 kB (+100.00%) πŸ”Ί
Carousel 2.48 kB +2.48 kB (+100.00%) πŸ”Ί
CarouselDrag 2.48 kB +2.48 kB (+100.00%) πŸ”Ί
CarouselWrapper 2.47 kB +2.47 kB (+100.00%) πŸ”Ί
AbstractCarouselChild 2.47 kB +2.47 kB (+100.00%) πŸ”Ί
CarouselItem 2.47 kB +2.47 kB (+100.00%) πŸ”Ί
Indexable 921 B +921 B (+100.00%) πŸ”Ί
withIndex 875 B +875 B (+100.00%) πŸ”Ί
Unchanged

@studiometa/ui

Name Size Diff
AbstractFrameTrigger 1.55 kB -
AbstractPrefetch 366 B -
AbstractScrollAnimation 3.41 kB -
AbstractSliderChild 600 B -
Accordion 1.77 kB -
AccordionItem 1.75 kB -
Action 1.01 kB -
AnchorNav 3.49 kB -
AnchorNavLink 3.37 kB -
AnchorNavTarget 125 B -
AnchorScrollTo 2.38 kB -
animationScrollWithEase 648 B -
CircularMarquee 543 B -
Cursor 650 B -
DataBind 653 B -
DataComputed 795 B -
DataEffect 771 B -
DataModel 731 B -
Draggable 1.63 kB -
Figure 1.51 kB -
FigureShopify 1.77 kB -
FigureTwicpics 2.05 kB -
FigureVideo 1.66 kB -
FigureVideoTwicpics 2.24 kB -
Frame 3.18 kB -
FrameAnchor 1.63 kB -
FrameForm 1.73 kB -
FrameLoader 1.24 kB -
FrameTarget 1.42 kB -
FrameTriggerLoader 1.26 kB -
Hoverable 947 B -
LargeText 707 B -
LazyInclude 322 B -
Menu 2.12 kB -
MenuBtn 140 B -
MenuList 1.7 kB -
Modal 1.99 kB -
ModalWithTransition 2.09 kB -
Panel 2.37 kB -
PrefetchWhenOver 408 B -
PrefetchWhenVisible 417 B -
ScrollAnimation 3.47 kB -
ScrollAnimationChild 3.6 kB -
ScrollAnimationChildWithEase 4.15 kB -
ScrollAnimationParent 3.66 kB -
ScrollAnimationWithEase 4.01 kB -
ScrollReveal 1.43 kB -
Sentinel 129 B -
Slider 2.33 kB -
SliderBtn 817 B -
SliderCount 650 B -
SliderDots 1.66 kB -
SliderDrag 269 B -
SliderItem 994 B -
SliderProgress 958 B -
Sticky 771 B -
Tabs 1.38 kB -
Target 86 B -
Transition 1.2 kB -
withDeprecation 166 B -
withTransition 1.19 kB -

@codecov
Copy link

codecov bot commented Nov 16, 2024

Codecov Report

❌ Patch coverage is 98.58156% with 6 lines in your changes missing coverage. Please review.
βœ… Project coverage is 91.16%. Comparing base (9516cab) to head (c68468c).
⚠️ Report is 28 commits behind head on develop.

Files with missing lines Patch % Lines
packages/ui/decorators/withIndex.ts 95.31% 6 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #320      +/-   ##
=============================================
+ Coverage      88.87%   91.16%   +2.29%     
  Complexity        20       20              
=============================================
  Files             99      110      +11     
  Lines           3515     3951     +436     
  Branches         487      619     +132     
=============================================
+ Hits            3124     3602     +478     
+ Misses           391      349      -42     
Flag Coverage Ξ”
unittests 91.16% <98.58%> (+2.29%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

β˜” View full report in Codecov by Sentry.
πŸ“’ Have feedback on the report? Share it here.

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@titouanmathis titouanmathis force-pushed the feature/carousel branch 11 times, most recently from 47ee71b to 69cadc7 Compare June 25, 2025 09:03
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

@antoine4livre antoine4livre self-assigned this Oct 2, 2025
@antoine4livre antoine4livre requested a review from a team October 2, 2025 13:01
@antoine4livre
Copy link
Contributor

antoine4livre commented Oct 2, 2025

@titouanmathis
New playground with the withIndexDecorator, CarouselBtn for bullets and progress based on a CSS variable.

Very long URL, so if it's not working:

{% set colors = ['red', 'green', 'blue', 'purple'] %}
{% set sizes = ['30', '40', '50'] %}
{% set count = 10 %}
<div data-component="Carousel">
  <div data-component="CarouselWrapper CarouselDrag" class="flex items-center gap-20 w-full p-20 overflow-x-auto snap-x snap-mandatory scrollbar-none">
    {% for i in 1..count %}
      {% set color = colors[loop.index0 % colors|length] %}
      {% set width = random(sizes) %}
      {% set height = random(sizes) %}
      <div 
        data-component="CarouselItem" 
        class="
          snap-center shrink-0 flex items-center justify-center 
          w-[{{ width }}vw] h-[{{ height }}vh] bg-{{ color }}-400 ring-inset ring-{{ color }}-600 
          text-white font-bold rounded-xl
        ">
        NΒ°{{ i }}
      </div>
    {% endfor %}
  </div>
  <div class="px-20 py-5">
    <div class="relative w-full h-2 rounded-full bg-gray-200 overflow-hidden">
      <div class="absolute inset-0 origin-left scale-x-[var(--carousel-progress)] bg-black rounded-full"></div>
    </div>
  </div>
  <nav class="flex items-center justify-center px-20 py-5 gap-10">
    {% include '@ui/Button/StyledButton.twig' with { 
      label: '← Prev', 
      attr: { 
        data_component: 'CarouselBtn',
        data_option_action: 'prev',
      } 
    } %}
    {% include '@ui/Button/StyledButton.twig' with { 
      label: 'Next β†’', 
      attr: { 
        data_component: 'CarouselBtn',
        data_option_action: 'next',
      } 
    } %}
  </nav>
  <nav data-component="CarouselDots" class="flex flex-wrap items-center justify-center px-20 py-5 gap-10">
    {% for i in 1..count %}
      {% include '@ui/Button/StyledButton.twig' with { 
      label: i, 
      theme: 'secondary',
      attr: { 
        data_component: 'CarouselBtn',
        data_option_action: loop.index0,
        class: 'disabled:ring-current',
      } 
    } %}
    {% endfor %}
  </nav>
</div>
import { Base, createApp, withDrag, withMountOnMediaQuery, getClosestParent } from 'https://esm.sh/@studiometa/js-toolkit@3.0.5';
import { clamp, isString, randomInt, inertiaFinalValue, domScheduler } from 'https://esm.sh/@studiometa/js-toolkit@3.0.5/utils';
import { compute } from 'https://esm.sh/compute-scroll-into-view';

const INDEXABLE_MODES = {
  NORMAL: 'normal',
  INFINITE: 'infinite',
  ALTERNATE: 'alternate',
};

const INDEXABLE_INSTRUCTIONS = {
  NEXT: 'next',
  PREVIOUS: 'previous',
  FIRST: 'first',
  LAST: 'last',
  RANDOM: 'random',
};

/**
 * Extend a class to add index management.
 */
function withIndex(BaseClass) {
  /**
   * Class.
   */
  class Indexable extends BaseClass {
    /**
     * Config.
     */
    static config = {
      ...BaseClass.config,
      emits: ['index'],
      options: {
        mode: {
          type: String,
          default: INDEXABLE_MODES.NORMAL,
        },
        reverse: Boolean,
      },
    };

    static MODES = INDEXABLE_MODES;

    static INSTRUCTIONS = INDEXABLE_INSTRUCTIONS;

    __index = 0;

    get isReverse() {
      return this.$options.reverse === true;
    }

    set isReverse(value) {
      this.$options.reverse = !!value;
    }

    get mode() {
      return Indexable.MODES[this.$options.mode.toUpperCase()] ?? Indexable.MODES.NORMAL;
    }

    set mode(value) {
      this.$options.mode = Indexable.MODES[value.toUpperCase()] ?? Indexable.MODES.NORMAL;
    }

    get length() {
      this.$warn('The length property should be overridden to match with the actual number of items. Finite length is required for infinite and alternate modes.');
      return Number.POSITIVE_INFINITY;
    }

    get minIndex() {
      return 0;
    }

    get maxIndex() {
      return this.length - 1;
    }

    get currentIndex() {
      return this.__index;
    }

    set currentIndex(value) {
      switch (this.mode) {
        case Indexable.MODES.ALTERNATE:
          if (Math.floor(value / this.length) % 2 !== 0) {
            this.isReverse = !this.isReverse;
          }
          const cycleLength = this.length * 2;
          const cycleIndex = Math.abs(value) % cycleLength;
          this.__index = Math.min(cycleIndex, cycleLength - cycleIndex);
          break;
        case Indexable.MODES.INFINITE:
          this.__index = (value + this.length) % this.length;
          break;
        default:
          this.__index = clamp(value, this.minIndex, this.maxIndex);
          break;
      }
    }

    get firstIndex() {
      return this.minIndex;
    }

    get lastIndex() {
      return this.maxIndex;
    }

    get prevIndex() {
      return this.currentIndex - 1;
    }

    get nextIndex() {
      return this.currentIndex + 1;
    }

    async goTo(indexOrInstruction) {
      if (indexOrInstruction === undefined) {
        return;
      }
      let index;
      if (isString(indexOrInstruction)) {
        switch (indexOrInstruction) {
          case Indexable.INSTRUCTIONS.NEXT:
            index = this.nextIndex;
            break;
          case Indexable.INSTRUCTIONS.PREVIOUS:
            index = this.prevIndex;
            break;
          case Indexable.INSTRUCTIONS.FIRST:
            index = this.firstIndex;
            break;
          case Indexable.INSTRUCTIONS.LAST:
            index = this.lastIndex;
            break;
          case Indexable.INSTRUCTIONS.RANDOM:
            index = randomInt(this.minIndex, this.maxIndex);
            break;
          default:
            index = this.currentIndex;
            break;
        }
      } else {
        index = indexOrInstruction;
      }
      this.currentIndex = index;
      await this.$emit('index', this.currentIndex);
    }

    async goNext() {
      await this.goTo(Indexable.INSTRUCTIONS.NEXT);
    }

    async goPrev() {
      await this.goTo(Indexable.INSTRUCTIONS.PREVIOUS);
    }
  }

  return Indexable;
}

/**
 * Indexable class.
 */
class Indexable extends withIndex(Base) {
  /**
   * Config.
   */
  static config = {
    name: 'Indexable',
    emits: ['index']
  };
}

/**
 * AbstractCarouselChild class.
 */
class AbstractCarouselChild extends Base {
  /**
   * Config.
   */
  static config = {
    name: 'AbstractCarouselChild',
    emits: ['parent-carousel-index', 'parent-carousel-progress'],
  };

  /**
   * Get the parent carousel instance.
   * @todo data-option-carousel for better grouping?
   */
  get carousel() {
    return getClosestParent(this, Carousel);
  }

  /**
   * Is the carousel horizontal?
   */
  get isHorizontal() {
    return this.carousel.isHorizontal;
  }

  /**
   * Is the carousel vertical?
   */
  get isVertical() {
    return this.carousel.isVertical;
  }

  /**
   * Disptach events from the parent carousel on the child components.
   */
  handleEvent(event) {
    switch (event.type) {
      case 'index':
      case 'progress':
        this.$emit(`parent-carousel-${event.type}`, ...event.detail);
        break;
    }
  }

  /**
   * Mounted hook.
   */
  mounted() {
    const { carousel } = this;

    if (!carousel) {
      this.$warn('Could not find a parent slider, not mounting.');
      this.$destroy();
      return;
    }

    carousel.$on('index', this);
    carousel.$on('progress', this);
  }

  /**
   * Destroyed hook.
   */
  destroyed() {
    this.carousel?.$off?.('index', this);
    this.carousel?.$off?.('progress', this);
  }
}

/**
 * CarouselBtn class.
 */
class CarouselBtn extends AbstractCarouselChild {
  /**
   * Config.
   */
  static config = {
    name: 'CarouselBtn',
    options: { action: String },
  };

  /**
   * Go to the next or previous item on click.
   */
  onClick() {
    const { action } = this.$options;
    switch (action) {
      case 'next':
        this.carousel.goNext();
        break;
      case 'prev':
        this.carousel.goPrev();
        break;
      default:
        this.carousel.goTo(Number(action));
        break;
    }
  }

  /**
   * Update button state on parent carousel progress.
   */
  onParentCarouselProgress() {
    const { action } = this.$options;
    const { currentIndex, lastIndex } = this.carousel;
    const shouldDisable =
      (action === 'next' && currentIndex === lastIndex) ||
      (action === 'prev' && currentIndex === 0) ||
      Number(action) === currentIndex;

    this.$el.disabled = shouldDisable;
  }
}

/**
 * Get the index of the closest number to the target.
 * @param {number[]} numbers - Array of numbers to search through
 * @param {number} target - Target number to find the closest match to
 * @returns {number} Index of the closest number
 */
function getClosestIndex(numbers, target) {
  let index = 0;
  let min = Number.POSITIVE_INFINITY;
  let closestIndex = 0;

  for (const number of numbers) {
    const absoluteDiff = Math.abs(number - target);

    if (absoluteDiff < min) {
      closestIndex = index;
      min = absoluteDiff;
    }

    index += 1;
  }

  return closestIndex;
}

/**
 * CarouselDrag class.
 */
class CarouselDrag extends withMountOnMediaQuery(
  withDrag(AbstractCarouselChild),
  '(pointer: fine)'
) {
  /**
   * Config.
   */
  static config = {
    name: 'CarouselDrag',
  };

  /**
   * Dragged hook.
   */
  dragged(props) {
    if (!this.$isMounted) return;

    // do nothing on inertia and stop
    if (props.mode === 'inertia' || props.mode === 'stop') {
      return;
    }

    // do nothing while the distance is 0
    if (
      (this.isHorizontal && props.distance.x === 0) ||
      (this.isVertical && props.distance.y === 0)
    ) {
      return;
    }

    const wrapper = this.$el;

    // @todo wait for the props.delta values to be fixed
    // @see https://github.com/studiometa/js-toolkit/pull/533
    if (props.mode === 'drag') {
      const left = wrapper.scrollLeft - props.delta.x;
      const top = wrapper.scrollTop - props.delta.y;
      // We must disable the scroll-snap otherwise we
      // cannot programmatically scroll to a position
      // that is not a snap-point. This might be easily
      // fixed by not using scroll-snap at all.
      wrapper.style.scrollSnapType = 'none';
      wrapper.scrollTo({ left, top, behavior: 'instant' });
      return;
    }

    // @todo implement inertia with the raf service for a smoother transition than the native smooth scroll
    if (props.mode === 'drop') {
      const options = { behavior: 'smooth' };

      if (this.isHorizontal) {
        const finalValue = inertiaFinalValue(wrapper.scrollLeft, props.delta.x * -2.5);
        const index = getClosestIndex(
          this.carousel.items.map((item) => item.state.left),
          finalValue
        );
        options.left = this.carousel.items[index].state.left;
      } else if (this.isVertical) {
        const finalValue = inertiaFinalValue(wrapper.scrollTop, props.delta.y * -2.5);
        const index = getClosestIndex(
          this.carousel.items.map((item) => item.state.top),
          finalValue
        );
        options.top = this.carousel.items[index].state.top;
      }

      wrapper.addEventListener(
        'scrollend',
        () => {
          wrapper.style.scrollSnapType = '';
        },
        { once: true }
      );
      wrapper.scrollTo(options);
    }
  }
}

/**
 * CarouselItem class.
 */
class CarouselItem extends AbstractCarouselChild {
  /**
   * Config.
   */
  static config = {
    name: 'CarouselItem',
  };

  /**
   * The item's index in the carousel.
   */
  get index() {
    return this.carousel.$children.CarouselItem.indexOf(this);
  }

  __state = null;
  __shouldEvaluateState = true;

  /**
   * The item's active state descriptor.
   */
  get state() {
    if (this.__shouldEvaluateState) {
      const [state] = compute(this.$el, {
        block: 'center',
        inline: 'center',
        boundary: this.carousel.wrapper.$el,
      });
      this.__state = state;
      this.__shouldEvaluateState = false;
    }

    return this.__state;
  }

  resized() {
    this.__shouldEvaluateState = true;
  }

  /**
   * Update the item's state on parent carousel progress.
   * @todo a11y
   */
  onParentCarouselProgress() {
    domScheduler.read(() => {
      const { index } = this;
      const { currentIndex: carouselIndex } = this.carousel;

      domScheduler.write(() => {
        this.$el.style.setProperty(
          '--carousel-item-active',
          String(Number(index === carouselIndex))
        );
      });
    });
  }
}

/**
 * CarouselWrapper class.
 */
class CarouselWrapper extends AbstractCarouselChild {
  /**
   * Config.
   */
  static config = {
    name: 'CarouselWrapper',
  };

  /**
   * Current progress between 0 and 1.
   */
  get progress() {
    if (this.isHorizontal) {
      const { scrollLeft, scrollWidth, offsetWidth } = this.$el;
      return scrollWidth - offsetWidth === 0 ? 0 : scrollLeft / (scrollWidth - offsetWidth);
    } else if (this.isVertical) {
      const { scrollTop, scrollHeight, offsetHeight } = this.$el;
      return scrollHeight - offsetHeight === 0 ? 0 : scrollTop / (scrollHeight - offsetHeight);
    }

    return 0;
  }

  /**
   * Update index and emit progress on wrapper scroll.
   */
  onScroll() {
    const { isHorizontal, $el, carousel } = this;

    const minDiffIndex = getClosestIndex(
      carousel.items.map((item) => (isHorizontal ? item.state.left : item.state.top)),
      isHorizontal ? $el.scrollLeft : $el.scrollTop
    );

    carousel.currentIndex = minDiffIndex;
    this.carousel.$services.enable('ticked');
  }

  /**
   * Scroll to the new item on parent carousel go-to event.
   */
  onParentCarouselIndex() {
    const { state } = this.carousel.items[this.carousel.currentIndex];
    if (state) {
      this.$el.scrollTo({ left: state.left, top: state.top, behavior: 'smooth' });
    }
  }
}

/**
 * Carousel class.
 */
class Carousel extends Indexable {
  /**
   * Config.
   */
  static config = {
    name: 'Carousel',
    components: {
      CarouselBtn,
      CarouselDrag,
      CarouselItem,
      CarouselWrapper,
    },
    options: {
      ...Indexable.config.options,
      axis: { type: String, default: 'x' },
    },
    emits: ['progress'],
  };

  /**
   * Previous progress value.
   */
  previousProgress = -1;

  /**
   * Is the carousel horizontal?
   */
  get isHorizontal() {
    return !this.isVertical;
  }

  /**
   * Is the carousel vertical?
   */
  get isVertical() {
    return this.$options.axis === 'y';
  }

  /**
   * Get the carousel's items.
   */
  get items() {
    return this.$children.CarouselItem;
  }

  /**
   * Get the carousel's length.
   */
  get length() {
    return this.items?.length || 0;
  }

  /**
   * Get the carousel's wrapper.
   */
  get wrapper() {
    return this.$children.CarouselWrapper?.[0];
  }

  /**
   * Progress from 0 to 1.
   */
  get progress() {
    return this.wrapper?.progress ?? 0;
  }

  /**
   * Mounted hook.
   */
  mounted() {
    this.goTo(this.currentIndex);
  }

  /**
   * Resized hook.
   */
  resized() {
    this.goTo(this.currentIndex);
  }

  /**
   * Go to the given item.
   */
  goTo(indexOrInstruction) {
    this.$log('goTo', indexOrInstruction);
    this.$services.enable('ticked');
    return super.goTo(indexOrInstruction);
  }

  ticked() {
    if (this.progress !== this.previousProgress) {
      this.previousProgress = this.progress;
      this.$emit('progress', this.progress);
      this.$el.style.setProperty('--carousel-progress', String(this.progress));
    } else {
      this.$services.disable('ticked');
    }
  }
}

class App extends Base {
  static config = {
    name: 'App',
    components: {
      Carousel,
    },
  };
}

createApp(App, {
  blocking: true,
})
html.dark {
  background-color: #222;
  color: #eee;
}

.scrollbar-none {
  -ms-overflow-style: none; /* Internet Explorer 10+ */
  scrollbar-width: none; /* Firefox */
  &::-webkit-scrollbar {
    display: none; /* Safari and Chrome */
  }
}

const wrapper = this.$el;

// @todo wait for the props.delta values to be fixed
// @see https://github.com/studiometa/js-toolkit/pull/533
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@titouanmathis is it ok now ? Do I need to edit something according to the merge of !533 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

studiometa/js-toolkit#533 has been merged and published in v3.0.0, so the @todo can be removed I think

@antoine4livre
Copy link
Contributor

@titouanmathis What was the bug with buttons on Chrome ?

Fix a bug with the prev/next buttons on Chrome

@antoine4livre antoine4livre added the enhancement New feature or request label Oct 2, 2025
@titouanmathis
Copy link
Contributor Author

@titouanmathis What was the bug with buttons on Chrome ?

Fix a bug with the prev/next buttons on Chrome

If I remember correctly, the animation was a bit laggy when using the buttons.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants