Skip to content

Commit 88328f6

Browse files
authored
feat(packages): ui: add carousel (#60)
1 parent d83874c commit 88328f6

16 files changed

+473
-0
lines changed

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@vueuse/shared": "12.7.0",
3636
"aria-hidden": "1.2.4",
3737
"defu": "6.1.4",
38+
"embla-carousel-vue": "8.5.2",
3839
"fuse.js": "7.1.0",
3940
"lucide-vue-next": "0.475.0",
4041
"ohash": "1.1.4",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { carouselVariants, cn } from '@soybean-ui/variants';
4+
import { useCarousel } from './context';
5+
import type { CarouselContentProps } from './types';
6+
7+
defineOptions({
8+
name: 'SCarouselContent',
9+
inheritAttrs: false
10+
});
11+
12+
const { class: cls, wrapperClass } = defineProps<CarouselContentProps>();
13+
14+
const { carouselRef, orientation } = useCarousel();
15+
16+
const mergedCls = computed(() => {
17+
const { content, contentWrapper } = carouselVariants({ orientation });
18+
19+
return {
20+
cls: cn(content(), cls),
21+
wrapper: cn(contentWrapper(), wrapperClass)
22+
};
23+
});
24+
</script>
25+
26+
<template>
27+
<div ref="carouselRef" :class="mergedCls.wrapper">
28+
<div v-bind="$attrs" :class="mergedCls.cls">
29+
<slot />
30+
</div>
31+
</div>
32+
</template>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { carouselVariants, cn } from '@soybean-ui/variants';
4+
import { useCarousel } from './context';
5+
import type { CarouselItemProps } from './types';
6+
7+
defineOptions({
8+
name: 'SCarouselItem'
9+
});
10+
11+
const { class: cls } = defineProps<CarouselItemProps>();
12+
13+
const { orientation } = useCarousel();
14+
15+
const mergedCls = computed(() => {
16+
const { item } = carouselVariants({ orientation });
17+
18+
return cn(item(), cls);
19+
});
20+
</script>
21+
22+
<template>
23+
<div :class="mergedCls" role="group" aria-roledescription="slide">
24+
<slot />
25+
</div>
26+
</template>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { useOmitForwardProps } from '@soybean-ui/primitives';
4+
import { carouselVariants, cn } from '@soybean-ui/variants';
5+
import { ChevronRight } from 'lucide-vue-next';
6+
import SButton from '../button/button.vue';
7+
import { useCarousel } from './context';
8+
import type { CarouselNextProps } from './types';
9+
10+
defineOptions({
11+
name: 'SCarouselNext'
12+
});
13+
14+
const props = withDefaults(defineProps<CarouselNextProps>(), {
15+
variant: 'pure',
16+
shape: 'circle'
17+
});
18+
19+
const forwardedProps = useOmitForwardProps(props, ['class', 'disabled']);
20+
21+
const { orientation, canScrollNext, scrollNext } = useCarousel();
22+
23+
const mergedCls = computed(() => {
24+
const { next } = carouselVariants({ orientation });
25+
26+
return cn(next(), props.class);
27+
});
28+
</script>
29+
30+
<template>
31+
<SButton v-bind="forwardedProps" :class="mergedCls" :disabled="!canScrollNext || disabled" @click="scrollNext">
32+
<slot>
33+
<ChevronRight />
34+
</slot>
35+
</SButton>
36+
</template>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { useOmitForwardProps } from '@soybean-ui/primitives';
4+
import { carouselVariants, cn } from '@soybean-ui/variants';
5+
import { ChevronLeft } from 'lucide-vue-next';
6+
import SButton from '../button/button.vue';
7+
import { useCarousel } from './context';
8+
import type { CarouselPreviousProps } from './types';
9+
10+
defineOptions({
11+
name: 'SCarouselPrevious'
12+
});
13+
14+
const props = withDefaults(defineProps<CarouselPreviousProps>(), {
15+
variant: 'pure',
16+
shape: 'circle'
17+
});
18+
19+
const forwardedProps = useOmitForwardProps(props, ['class', 'disabled']);
20+
21+
const { orientation, canScrollPrev, scrollPrev } = useCarousel();
22+
23+
const mergedCls = computed(() => {
24+
const { previous } = carouselVariants({ orientation });
25+
26+
return cn(previous(), props.class);
27+
});
28+
</script>
29+
30+
<template>
31+
<SButton v-bind="forwardedProps" :class="mergedCls" :disabled="!canScrollPrev || disabled" @click="scrollPrev">
32+
<slot>
33+
<ChevronLeft />
34+
</slot>
35+
</SButton>
36+
</template>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { carouselVariants, cn } from '@soybean-ui/variants';
4+
import type { CarouselRootEmits, CarouselRootProps } from './types';
5+
import { provideCarouselContext } from './context';
6+
7+
defineOptions({
8+
name: 'SCarouselRoot'
9+
});
10+
11+
const props = withDefaults(defineProps<CarouselRootProps>(), {
12+
orientation: 'horizontal'
13+
});
14+
15+
const emit = defineEmits<CarouselRootEmits>();
16+
17+
const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } =
18+
provideCarouselContext(props, emit);
19+
20+
const mergedCls = computed(() => {
21+
const { root } = carouselVariants({ orientation: props.orientation });
22+
23+
return cn(root(), props.class);
24+
});
25+
26+
function onKeyDown(event: KeyboardEvent) {
27+
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
28+
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
29+
30+
if (event.key === prevKey) {
31+
event.preventDefault();
32+
scrollPrev();
33+
34+
return;
35+
}
36+
37+
if (event.key === nextKey) {
38+
event.preventDefault();
39+
scrollNext();
40+
}
41+
}
42+
43+
defineExpose({
44+
canScrollNext,
45+
canScrollPrev,
46+
carouselApi,
47+
carouselRef,
48+
orientation,
49+
scrollNext,
50+
scrollPrev
51+
});
52+
</script>
53+
54+
<template>
55+
<div :class="mergedCls" role="region" aria-roledescription="carousel" tabindex="0" @keydown="onKeyDown">
56+
<slot
57+
:can-scroll-next="canScrollNext"
58+
:can-scroll-prev="canScrollPrev"
59+
:carousel-api="carouselApi"
60+
:carousel-ref="carouselRef"
61+
:orientation="orientation"
62+
:scroll-next="scrollNext"
63+
:scroll-prev="scrollPrev"
64+
/>
65+
</div>
66+
</template>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script setup lang="ts">
2+
import SCarouselRoot from './carousel-root.vue';
3+
import SCarouselContent from './carousel-content.vue';
4+
import SCarouselItem from './carousel-item.vue';
5+
import SCarouselNext from './carousel-next.vue';
6+
import SCarouselPrevious from './carousel-previous.vue';
7+
import type { CarouselEmits, CarouselProps } from './types';
8+
9+
defineOptions({
10+
name: 'SCarousel'
11+
});
12+
13+
const { class: cls, ui, counts, nextProps, previousProps, ...delegatedProps } = defineProps<CarouselProps>();
14+
15+
const emit = defineEmits<CarouselEmits>();
16+
</script>
17+
18+
<template>
19+
<SCarouselRoot v-bind="delegatedProps" :class="cls || ui?.root" @init-api="emit('initApi', $event)">
20+
<SCarouselContent :class="ui?.content" :wrapper-class="ui?.contentWrapper">
21+
<SCarouselItem v-for="i in counts" :key="i" :class="ui?.item">
22+
<slot :index="i" />
23+
</SCarouselItem>
24+
</SCarouselContent>
25+
<SCarouselNext v-bind="nextProps" :class="ui?.next" />
26+
<SCarouselPrevious v-bind="previousProps" :class="ui?.previous" />
27+
</SCarouselRoot>
28+
</template>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { onMounted, ref } from 'vue';
2+
import { useContext } from '@soybean-ui/primitives';
3+
import emblaCarouselVue from 'embla-carousel-vue';
4+
import type { CarouselContext, CarouselContextParams, CarouselRootEmits, UnwrapRefCarouselApi } from './types';
5+
6+
const [provideCarouselContext, injectCarouselContext] = useContext(
7+
'Carousel',
8+
(params: CarouselContextParams, emit: CarouselRootEmits) => {
9+
const { orientation, opts, plugins } = params;
10+
11+
const [carouselRef, carouselApi] = emblaCarouselVue(
12+
{
13+
...opts,
14+
axis: orientation === 'horizontal' ? 'x' : 'y'
15+
},
16+
plugins
17+
);
18+
19+
const canScrollNext = ref(false);
20+
const canScrollPrev = ref(false);
21+
22+
function onSelect(api: UnwrapRefCarouselApi) {
23+
canScrollNext.value = api?.canScrollNext() || false;
24+
canScrollPrev.value = api?.canScrollPrev() || false;
25+
}
26+
27+
function init() {
28+
if (!carouselApi.value) return;
29+
30+
carouselApi.value?.on('init', onSelect);
31+
carouselApi.value?.on('reInit', onSelect);
32+
carouselApi.value?.on('select', onSelect);
33+
34+
emit('initApi', carouselApi.value);
35+
}
36+
37+
const context: CarouselContext = {
38+
carouselRef,
39+
carouselApi,
40+
canScrollPrev,
41+
canScrollNext,
42+
scrollNext: () => {
43+
carouselApi.value?.scrollNext();
44+
},
45+
scrollPrev: () => {
46+
carouselApi.value?.scrollPrev();
47+
},
48+
orientation
49+
};
50+
51+
onMounted(() => {
52+
init();
53+
});
54+
55+
return context;
56+
}
57+
);
58+
59+
function useCarousel() {
60+
const carouselContext = injectCarouselContext();
61+
62+
if (!carouselContext) throw new Error('useCarousel must be used within a <CarouselRoot />');
63+
64+
return carouselContext;
65+
}
66+
67+
export { useCarousel, provideCarouselContext };
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SCarouselRoot from './carousel-root.vue';
2+
import SCarouselContent from './carousel-content.vue';
3+
import SCarouselItem from './carousel-item.vue';
4+
import SCarouselNext from './carousel-next.vue';
5+
import SCarouselPrevious from './carousel-previous.vue';
6+
import SCarousel from './carousel.vue';
7+
8+
export { SCarouselRoot, SCarouselContent, SCarouselItem, SCarouselNext, SCarouselPrevious, SCarousel };
9+
10+
export * from './types';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { Ref, UnwrapRef } from 'vue';
2+
import type { CarouselSlots, ThemeOrientation } from '@soybean-ui/variants';
3+
import type { ClassValue, ClassValueProp } from '@soybean-ui/primitives';
4+
import type useEmblaCarousel from 'embla-carousel-vue';
5+
import type { EmblaCarouselVueType } from 'embla-carousel-vue';
6+
import type { ButtonProps } from '../button/types';
7+
8+
type CarouselApi = EmblaCarouselVueType[1];
9+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
10+
type CarouselOptions = UseCarouselParameters[0];
11+
type CarouselPlugin = UseCarouselParameters[1];
12+
13+
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>;
14+
15+
export interface CarouselContextParams {
16+
orientation?: ThemeOrientation;
17+
opts?: CarouselOptions;
18+
plugins?: CarouselPlugin;
19+
}
20+
21+
export interface CarouselContext {
22+
carouselRef: Ref<HTMLElement | undefined>;
23+
carouselApi: Ref<UnwrapRefCarouselApi | undefined>;
24+
canScrollPrev: Ref<boolean>;
25+
canScrollNext: Ref<boolean>;
26+
scrollNext: () => void;
27+
scrollPrev: () => void;
28+
orientation?: ThemeOrientation;
29+
}
30+
31+
export interface CarouselRootProps extends ClassValueProp, CarouselContextParams {}
32+
33+
export type CarouselRootEmits = {
34+
(e: 'initApi', payload: UnwrapRefCarouselApi): void;
35+
};
36+
37+
export interface CarouselContentProps extends ClassValueProp {
38+
wrapperClass?: ClassValue;
39+
}
40+
41+
export interface CarouselItemProps extends ClassValueProp {}
42+
43+
export interface CarouselNextProps extends ButtonProps {}
44+
45+
export interface CarouselPreviousProps extends ButtonProps {}
46+
47+
export type CarouselUi = Partial<Record<CarouselSlots, ClassValue>>;
48+
49+
export interface CarouselProps extends CarouselRootProps {
50+
counts?: number;
51+
ui?: CarouselUi;
52+
nextProps?: Omit<ButtonProps, 'class'>;
53+
previousProps?: Omit<ButtonProps, 'class'>;
54+
}
55+
56+
export type CarouselEmits = CarouselRootEmits;

packages/ui/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './badge';
77
export * from './breadcrumb';
88
export * from './button';
99
export * from './card';
10+
export * from './carousel';
1011
export * from './checkbox';
1112
export * from './chip';
1213
export * from './collapsible';

packages/variants/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './variants/breadcrumb';
77
export * from './variants/button';
88
export * from './variants/button-group';
99
export * from './variants/card';
10+
export * from './variants/carousel';
1011
export * from './variants/checkbox';
1112
export * from './variants/chip';
1213
export * from './variants/collapsible';

0 commit comments

Comments
 (0)