Skip to content

feat(packages): ui: add carousel #60

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

Merged
merged 1 commit into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@vueuse/shared": "12.7.0",
"aria-hidden": "1.2.4",
"defu": "6.1.4",
"embla-carousel-vue": "8.5.2",
"fuse.js": "7.1.0",
"lucide-vue-next": "0.475.0",
"ohash": "1.1.4",
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/components/carousel/carousel-content.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue';
import { carouselVariants, cn } from '@soybean-ui/variants';
import { useCarousel } from './context';
import type { CarouselContentProps } from './types';

defineOptions({
name: 'SCarouselContent',
inheritAttrs: false
});

const { class: cls, wrapperClass } = defineProps<CarouselContentProps>();

const { carouselRef, orientation } = useCarousel();

const mergedCls = computed(() => {
const { content, contentWrapper } = carouselVariants({ orientation });

return {
cls: cn(content(), cls),
wrapper: cn(contentWrapper(), wrapperClass)
};
});
</script>

<template>
<div ref="carouselRef" :class="mergedCls.wrapper">
<div v-bind="$attrs" :class="mergedCls.cls">
<slot />
</div>
</div>
</template>
26 changes: 26 additions & 0 deletions packages/ui/src/components/carousel/carousel-item.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue';
import { carouselVariants, cn } from '@soybean-ui/variants';
import { useCarousel } from './context';
import type { CarouselItemProps } from './types';

defineOptions({
name: 'SCarouselItem'
});

const { class: cls } = defineProps<CarouselItemProps>();

const { orientation } = useCarousel();

const mergedCls = computed(() => {
const { item } = carouselVariants({ orientation });

return cn(item(), cls);
});
</script>

<template>
<div :class="mergedCls" role="group" aria-roledescription="slide">
<slot />
</div>
</template>
36 changes: 36 additions & 0 deletions packages/ui/src/components/carousel/carousel-next.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useOmitForwardProps } from '@soybean-ui/primitives';
import { carouselVariants, cn } from '@soybean-ui/variants';
import { ChevronRight } from 'lucide-vue-next';
import SButton from '../button/button.vue';
import { useCarousel } from './context';
import type { CarouselNextProps } from './types';

defineOptions({
name: 'SCarouselNext'
});

const props = withDefaults(defineProps<CarouselNextProps>(), {
variant: 'pure',
shape: 'circle'
});

const forwardedProps = useOmitForwardProps(props, ['class', 'disabled']);

const { orientation, canScrollNext, scrollNext } = useCarousel();

const mergedCls = computed(() => {
const { next } = carouselVariants({ orientation });

return cn(next(), props.class);
});
</script>

<template>
<SButton v-bind="forwardedProps" :class="mergedCls" :disabled="!canScrollNext || disabled" @click="scrollNext">
<slot>
<ChevronRight />
</slot>
</SButton>
</template>
36 changes: 36 additions & 0 deletions packages/ui/src/components/carousel/carousel-previous.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useOmitForwardProps } from '@soybean-ui/primitives';
import { carouselVariants, cn } from '@soybean-ui/variants';
import { ChevronLeft } from 'lucide-vue-next';
import SButton from '../button/button.vue';
import { useCarousel } from './context';
import type { CarouselPreviousProps } from './types';

defineOptions({
name: 'SCarouselPrevious'
});

const props = withDefaults(defineProps<CarouselPreviousProps>(), {
variant: 'pure',
shape: 'circle'
});

const forwardedProps = useOmitForwardProps(props, ['class', 'disabled']);

const { orientation, canScrollPrev, scrollPrev } = useCarousel();

const mergedCls = computed(() => {
const { previous } = carouselVariants({ orientation });

return cn(previous(), props.class);
});
</script>

<template>
<SButton v-bind="forwardedProps" :class="mergedCls" :disabled="!canScrollPrev || disabled" @click="scrollPrev">
<slot>
<ChevronLeft />
</slot>
</SButton>
</template>
66 changes: 66 additions & 0 deletions packages/ui/src/components/carousel/carousel-root.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue';
import { carouselVariants, cn } from '@soybean-ui/variants';
import type { CarouselRootEmits, CarouselRootProps } from './types';
import { provideCarouselContext } from './context';

defineOptions({
name: 'SCarouselRoot'
});

const props = withDefaults(defineProps<CarouselRootProps>(), {
orientation: 'horizontal'
});

const emit = defineEmits<CarouselRootEmits>();

const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } =
provideCarouselContext(props, emit);

const mergedCls = computed(() => {
const { root } = carouselVariants({ orientation: props.orientation });

return cn(root(), props.class);
});

function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
const nextKey = props.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';

if (event.key === prevKey) {
event.preventDefault();
scrollPrev();

return;
}

if (event.key === nextKey) {
event.preventDefault();
scrollNext();
}
}

defineExpose({
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev
});
</script>

<template>
<div :class="mergedCls" role="region" aria-roledescription="carousel" tabindex="0" @keydown="onKeyDown">
<slot
:can-scroll-next="canScrollNext"
:can-scroll-prev="canScrollPrev"
:carousel-api="carouselApi"
:carousel-ref="carouselRef"
:orientation="orientation"
:scroll-next="scrollNext"
:scroll-prev="scrollPrev"
/>
</div>
</template>
28 changes: 28 additions & 0 deletions packages/ui/src/components/carousel/carousel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
import SCarouselRoot from './carousel-root.vue';
import SCarouselContent from './carousel-content.vue';
import SCarouselItem from './carousel-item.vue';
import SCarouselNext from './carousel-next.vue';
import SCarouselPrevious from './carousel-previous.vue';
import type { CarouselEmits, CarouselProps } from './types';

defineOptions({
name: 'SCarousel'
});

const { class: cls, ui, counts, nextProps, previousProps, ...delegatedProps } = defineProps<CarouselProps>();

const emit = defineEmits<CarouselEmits>();
</script>

<template>
<SCarouselRoot v-bind="delegatedProps" :class="cls || ui?.root" @init-api="emit('initApi', $event)">
<SCarouselContent :class="ui?.content" :wrapper-class="ui?.contentWrapper">
<SCarouselItem v-for="i in counts" :key="i" :class="ui?.item">
<slot :index="i" />
</SCarouselItem>
</SCarouselContent>
<SCarouselNext v-bind="nextProps" :class="ui?.next" />
<SCarouselPrevious v-bind="previousProps" :class="ui?.previous" />
</SCarouselRoot>
</template>
67 changes: 67 additions & 0 deletions packages/ui/src/components/carousel/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { onMounted, ref } from 'vue';
import { useContext } from '@soybean-ui/primitives';
import emblaCarouselVue from 'embla-carousel-vue';
import type { CarouselContext, CarouselContextParams, CarouselRootEmits, UnwrapRefCarouselApi } from './types';

const [provideCarouselContext, injectCarouselContext] = useContext(
'Carousel',
(params: CarouselContextParams, emit: CarouselRootEmits) => {
const { orientation, opts, plugins } = params;

const [carouselRef, carouselApi] = emblaCarouselVue(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y'
},
plugins
);

const canScrollNext = ref(false);
const canScrollPrev = ref(false);

function onSelect(api: UnwrapRefCarouselApi) {
canScrollNext.value = api?.canScrollNext() || false;
canScrollPrev.value = api?.canScrollPrev() || false;
}

function init() {
if (!carouselApi.value) return;

carouselApi.value?.on('init', onSelect);
carouselApi.value?.on('reInit', onSelect);
carouselApi.value?.on('select', onSelect);

emit('initApi', carouselApi.value);
}

const context: CarouselContext = {
carouselRef,
carouselApi,
canScrollPrev,
canScrollNext,
scrollNext: () => {
carouselApi.value?.scrollNext();
},
scrollPrev: () => {
carouselApi.value?.scrollPrev();
},
orientation
};

onMounted(() => {
init();
});

return context;
}
);

function useCarousel() {
const carouselContext = injectCarouselContext();

if (!carouselContext) throw new Error('useCarousel must be used within a <CarouselRoot />');

return carouselContext;
}

export { useCarousel, provideCarouselContext };
10 changes: 10 additions & 0 deletions packages/ui/src/components/carousel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SCarouselRoot from './carousel-root.vue';
import SCarouselContent from './carousel-content.vue';
import SCarouselItem from './carousel-item.vue';
import SCarouselNext from './carousel-next.vue';
import SCarouselPrevious from './carousel-previous.vue';
import SCarousel from './carousel.vue';

export { SCarouselRoot, SCarouselContent, SCarouselItem, SCarouselNext, SCarouselPrevious, SCarousel };

export * from './types';
56 changes: 56 additions & 0 deletions packages/ui/src/components/carousel/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Ref, UnwrapRef } from 'vue';
import type { CarouselSlots, ThemeOrientation } from '@soybean-ui/variants';
import type { ClassValue, ClassValueProp } from '@soybean-ui/primitives';
import type useEmblaCarousel from 'embla-carousel-vue';
import type { EmblaCarouselVueType } from 'embla-carousel-vue';
import type { ButtonProps } from '../button/types';

type CarouselApi = EmblaCarouselVueType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];

export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>;

export interface CarouselContextParams {
orientation?: ThemeOrientation;
opts?: CarouselOptions;
plugins?: CarouselPlugin;
}

export interface CarouselContext {
carouselRef: Ref<HTMLElement | undefined>;
carouselApi: Ref<UnwrapRefCarouselApi | undefined>;
canScrollPrev: Ref<boolean>;
canScrollNext: Ref<boolean>;
scrollNext: () => void;
scrollPrev: () => void;
orientation?: ThemeOrientation;
}

export interface CarouselRootProps extends ClassValueProp, CarouselContextParams {}

export type CarouselRootEmits = {
(e: 'initApi', payload: UnwrapRefCarouselApi): void;
};

export interface CarouselContentProps extends ClassValueProp {
wrapperClass?: ClassValue;
}

export interface CarouselItemProps extends ClassValueProp {}

export interface CarouselNextProps extends ButtonProps {}

export interface CarouselPreviousProps extends ButtonProps {}

export type CarouselUi = Partial<Record<CarouselSlots, ClassValue>>;

export interface CarouselProps extends CarouselRootProps {
counts?: number;
ui?: CarouselUi;
nextProps?: Omit<ButtonProps, 'class'>;
previousProps?: Omit<ButtonProps, 'class'>;
}

export type CarouselEmits = CarouselRootEmits;
1 change: 1 addition & 0 deletions packages/ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './badge';
export * from './breadcrumb';
export * from './button';
export * from './card';
export * from './carousel';
export * from './checkbox';
export * from './chip';
export * from './collapsible';
Expand Down
1 change: 1 addition & 0 deletions packages/variants/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './variants/breadcrumb';
export * from './variants/button';
export * from './variants/button-group';
export * from './variants/card';
export * from './variants/carousel';
export * from './variants/checkbox';
export * from './variants/chip';
export * from './variants/collapsible';
Expand Down
Loading