-
Notifications
You must be signed in to change notification settings - Fork 1
[Feature] Add a Carousel component #320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
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.
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 ?
There was a problem hiding this comment.
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
|
@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