diff --git a/packages/ui/package.json b/packages/ui/package.json index 179ea6b6..0fd61955 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/carousel/carousel-content.vue b/packages/ui/src/components/carousel/carousel-content.vue new file mode 100644 index 00000000..2b7d63e7 --- /dev/null +++ b/packages/ui/src/components/carousel/carousel-content.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/ui/src/components/carousel/carousel-item.vue b/packages/ui/src/components/carousel/carousel-item.vue new file mode 100644 index 00000000..0deddce4 --- /dev/null +++ b/packages/ui/src/components/carousel/carousel-item.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/ui/src/components/carousel/carousel-next.vue b/packages/ui/src/components/carousel/carousel-next.vue new file mode 100644 index 00000000..4d98a47d --- /dev/null +++ b/packages/ui/src/components/carousel/carousel-next.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/ui/src/components/carousel/carousel-previous.vue b/packages/ui/src/components/carousel/carousel-previous.vue new file mode 100644 index 00000000..b3cc214d --- /dev/null +++ b/packages/ui/src/components/carousel/carousel-previous.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/ui/src/components/carousel/carousel-root.vue b/packages/ui/src/components/carousel/carousel-root.vue new file mode 100644 index 00000000..1e1018bf --- /dev/null +++ b/packages/ui/src/components/carousel/carousel-root.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/ui/src/components/carousel/carousel.vue b/packages/ui/src/components/carousel/carousel.vue new file mode 100644 index 00000000..94891e2c --- /dev/null +++ b/packages/ui/src/components/carousel/carousel.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/ui/src/components/carousel/context.ts b/packages/ui/src/components/carousel/context.ts new file mode 100644 index 00000000..86bb184e --- /dev/null +++ b/packages/ui/src/components/carousel/context.ts @@ -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 '); + + return carouselContext; +} + +export { useCarousel, provideCarouselContext }; diff --git a/packages/ui/src/components/carousel/index.ts b/packages/ui/src/components/carousel/index.ts new file mode 100644 index 00000000..abc1f02e --- /dev/null +++ b/packages/ui/src/components/carousel/index.ts @@ -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'; diff --git a/packages/ui/src/components/carousel/types.ts b/packages/ui/src/components/carousel/types.ts new file mode 100644 index 00000000..72be4d03 --- /dev/null +++ b/packages/ui/src/components/carousel/types.ts @@ -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; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +export type UnwrapRefCarouselApi = UnwrapRef; + +export interface CarouselContextParams { + orientation?: ThemeOrientation; + opts?: CarouselOptions; + plugins?: CarouselPlugin; +} + +export interface CarouselContext { + carouselRef: Ref; + carouselApi: Ref; + canScrollPrev: Ref; + canScrollNext: Ref; + 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>; + +export interface CarouselProps extends CarouselRootProps { + counts?: number; + ui?: CarouselUi; + nextProps?: Omit; + previousProps?: Omit; +} + +export type CarouselEmits = CarouselRootEmits; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index b363b30f..7f8982e8 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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'; diff --git a/packages/variants/src/index.ts b/packages/variants/src/index.ts index 5f46b890..70c86cdf 100644 --- a/packages/variants/src/index.ts +++ b/packages/variants/src/index.ts @@ -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'; diff --git a/packages/variants/src/variants/carousel.ts b/packages/variants/src/variants/carousel.ts new file mode 100644 index 00000000..bac65828 --- /dev/null +++ b/packages/variants/src/variants/carousel.ts @@ -0,0 +1,34 @@ +// @unocss-include +import { tv } from 'tailwind-variants'; + +export const carouselVariants = tv({ + slots: { + root: 'relative', + contentWrapper: 'overflow-hidden', + content: 'flex', + item: 'min-w-0 shrink-0 grow-0 basis-full', + next: 'touch-manipulation absolute', + previous: 'touch-manipulation absolute' + }, + variants: { + orientation: { + horizontal: { + content: '-ml-4', + item: 'pl-4', + next: '-right-12 top-1/2 -translate-y-1/2', + previous: '-left-12 top-1/2 -translate-y-1/2' + }, + vertical: { + content: 'flex-col -mt-4', + item: 'pt-4', + next: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90', + previous: '-top-12 left-1/2 -translate-x-1/2 rotate-90' + } + } + }, + defaultVariants: { + orientation: 'horizontal' + } +}); + +export type CarouselSlots = keyof typeof carouselVariants.slots; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c10b063..bbae228f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: defu: specifier: 6.1.4 version: 6.1.4 + embla-carousel-vue: + specifier: ^8.5.2 + version: 8.5.2(vue@3.5.13(typescript@5.7.3)) fuse.js: specifier: 7.1.0 version: 7.1.0 @@ -2090,6 +2093,19 @@ packages: electron-to-chromium@1.5.102: resolution: {integrity: sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==} + embla-carousel-reactive-utils@8.5.2: + resolution: {integrity: sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==} + peerDependencies: + embla-carousel: 8.5.2 + + embla-carousel-vue@8.5.2: + resolution: {integrity: sha512-jPZKpst5auGJQ/GRs+UPc7KQGYd/zkwU+bA3m/SDCd4dsTpNScSmfBDWeB/SSUcc6G3z9GV+bOfyAJw1gZLUMA==} + peerDependencies: + vue: ^3.2.37 + + embla-carousel@8.5.2: + resolution: {integrity: sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==} + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -5642,6 +5658,18 @@ snapshots: electron-to-chromium@1.5.102: {} + embla-carousel-reactive-utils@8.5.2(embla-carousel@8.5.2): + dependencies: + embla-carousel: 8.5.2 + + embla-carousel-vue@8.5.2(vue@3.5.13(typescript@5.7.3)): + dependencies: + embla-carousel: 8.5.2 + embla-carousel-reactive-utils: 8.5.2(embla-carousel@8.5.2) + vue: 3.5.13(typescript@5.7.3) + + embla-carousel@8.5.2: {} + emoji-regex-xs@1.0.0: {} emoji-regex@10.4.0: {} diff --git a/src/views/ui/index.vue b/src/views/ui/index.vue index 4805f38a..29b1dd68 100644 --- a/src/views/ui/index.vue +++ b/src/views/ui/index.vue @@ -14,6 +14,7 @@ import UiBadge from './modules/badge.vue'; import UiBreadcrumb from './modules/breadcrumb.vue'; import UiButton from './modules/button.vue'; import UiCard from './modules/card.vue'; +import UiCarousel from './modules/carousel.vue'; import UiCheckbox from './modules/checkbox.vue'; import UiChip from './modules/chip.vue'; import UiCollapsible from './modules/collapsible.vue'; @@ -115,6 +116,11 @@ const tabs: TabConfig[] = [ label: 'Card', component: UiCard }, + { + value: 'carousel', + label: 'Carousel', + component: UiCarousel + }, { value: 'checkbox', label: 'Checkbox', diff --git a/src/views/ui/modules/carousel.vue b/src/views/ui/modules/carousel.vue new file mode 100644 index 00000000..d3c29775 --- /dev/null +++ b/src/views/ui/modules/carousel.vue @@ -0,0 +1,45 @@ + + +