Conversation
Export Size@studiometa/ui
Unchanged@studiometa/ui
|
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
187ef76 to
121132e
Compare
47ee71b to
69cadc7
Compare
5ce1481 to
10b6f9c
Compare
1ec237f to
192c22e
Compare
192c22e to
d6aa197
Compare
d6aa197 to
201446a
Compare
|
@titouanmathis 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 |
There was a problem hiding this comment.
@titouanmathis is it ok now ? Do I need to edit something according to the merge of !533 ?
There was a problem hiding this comment.
studiometa/js-toolkit#533 has been merged and published in v3.0.0, so the @todo can be removed I think
|
@titouanmathis What was the bug with buttons on Chrome ?
|
If I remember correctly, the animation was a bit laggy when using the buttons. |
To replace the Slider component with native scroll-snap for touch devices.
🔗 Linked issue
No issue.
❓ Type of 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
Ensure the Carousel implementation is agnostic enough to be able to create different types of animations(use indexable)CarouselDragon touch friendly devices📝 Checklist