Skip to content

Commit

Permalink
feat: add VCarouselProgress
Browse files Browse the repository at this point in the history
  • Loading branch information
Illawind committed Jan 14, 2025
1 parent 64d65f3 commit 974a1a5
Show file tree
Hide file tree
Showing 4 changed files with 196 additions and 94 deletions.
124 changes: 30 additions & 94 deletions components/molecules/VCarouselControls/VCarouselControls.vue
Original file line number Diff line number Diff line change
@@ -1,116 +1,52 @@
<script setup lang="ts">
import type { ThemeProps } from '#imports'
import { useCarouselControls } from '~/composables/use-carousel-controls'
interface VCarouselControlsProps extends ThemeProps {
slideLength?: number
type VCarouselControlsProps = ThemeProps & {
snapLength: number
displayNumbers?: boolean
isEnd?: boolean
}
const props = defineProps<VCarouselControlsProps>()
const scroll = ref<HTMLInputElement | null>(null)
const thumb = ref<HTMLInputElement | null>(null)
const index = defineModel<number>({ default: 0 })
onMounted(() => {
setIndicatorWidth()
setIndicatorPosition(index.value)
})
watch(() => props.snapLength, () => {
setIndicatorWidth()
setIndicatorPosition(index.value)
})
watch(index, (newIndex) => {
setIndicatorPosition(newIndex)
})
const slidePosition = computed(() => {
if (!props.displayNumbers) return
return `${formatValue(index.value)} / ${formatValue(props.snapLength - 1)}`
})
function formatValue(n: number) {
return (n < 9 ? '0' : '') + (n + 1)
}
function setIndicatorWidth() {
if (!scroll.value || !thumb.value) return // can be undefined in SSR
thumb.value?.style.setProperty('--v-carousel-controls-thumb-width', (scroll.value?.offsetWidth / props.snapLength / scroll.value?.offsetWidth) * 100 + '%')
}
function setIndicatorPosition(index: number) {
if (!scroll.value || !thumb.value) return
const percent = index / (props.snapLength - 1)
const translate = percent * (scroll.value?.offsetWidth - thumb.value?.getBoundingClientRect().width)
const props = defineProps<VCarouselControlsProps>()
thumb.value.style.translate = translate + 'px 0 '
}
const snapLength = ref(props.snapLength)
watch(() => props.snapLength, v => snapLength.value = v)
function onClick(event: Event) {
const direction = (event.currentTarget as HTMLButtonElement).name === 'next' ? 1 : -1
index.value = index.value + direction
}
const prevBtnDisabled = computed(() => index.value === 0)
const nextBtnDisabled = computed(() => {
return (index.value === props.snapLength - 1) || props.isEnd
const { numbersOutput, prevButtonAttrs, isCarouselDraggable, nextButtonAttrs } = useCarouselControls({
displayNumbers: props.displayNumbers,
snapLength,
index,
})
const isCarouselDraggable = computed(() => {
if (!props.slideLength) return true
return (props.slideLength > props.snapLength) && props.snapLength > 1
})
const isInert = ref(false)
onMounted(() => isInert.value = !isCarouselDraggable.value)
watch(isCarouselDraggable, value => isInert.value = !value)
const ariaHidden = ref(false)
onMounted(() => ariaHidden.value = !isCarouselDraggable.value)
watch(isCarouselDraggable, value => ariaHidden.value = !value)
</script>

<template>
<div
:class="[$style.root, isCarouselDraggable && $style['root--carousel-draggable']]"
:inert="isInert || undefined"
:aria-hidden="ariaHidden || undefined"
>
<div
ref="scroll"
:class="$style.scroll"
v-if="numbersOutput"
class="text-body-xs"
:class="$style.numbers"
>
<div
ref="thumb"
:class="$style.thumb"
/>
<span>{{ numbersOutput }}</span>
</div>
<VButton
name="previous"
icon-name="arrow-left"
:aria-label="$t('carousel.previous_slide_label')"
:disabled="prevBtnDisabled"
:theme="!!theme ? theme : undefined"
:class="$style.button"
@click="onClick"
v-bind="prevButtonAttrs"
:class="$style['button-prev']"
/>
<VCarouselProgress
:index="index"
:snap-length="snapLength"
/>
<div
v-if="slidePosition"
class="text-body-xs"
:class="$style.number"
>
<span>{{ slidePosition }}</span>
</div>
<VButton
name="next"
icon-name="arrow-right"
:aria-label="$t('carousel.next_slide_label')"
:disabled="nextBtnDisabled"
:theme="!!theme ? theme : undefined"
:class="$style.button"
@click="onClick"
v-bind="nextButtonAttrs"
:class="$style['button-next']"
/>
</div>
</template>
Expand All @@ -127,6 +63,10 @@ watch(isCarouselDraggable, value => isInert.value = !value)
opacity: 0;
transition: opacity 0.3s;
&[aria-hidden="true"] {
display: var(--v-carousel-controls-hidden-display, none);
}
&--carousel-draggable {
opacity: var(--v-carousel-controls-opacity, 1);
}
Expand Down Expand Up @@ -167,12 +107,8 @@ watch(isCarouselDraggable, value => isInert.value = !value)
transition-property: translate, width;
}
.number {
.numbers {
display: var(--v-carousel-controls-numbers-display, block);
@include media('>=lg') {
display: var(--v-carousel-controls-numbers-display, none);
}
}
.button {
Expand Down
12 changes: 12 additions & 0 deletions components/molecules/VCarouselProgress/Default.stories.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script setup lang="ts">
const index = ref(0)
</script>

<template>
<NuxtStory>
<VCarouselProgress
v-model:index="index"
:snap-length="3"
/>
</NuxtStory>
</template>
94 changes: 94 additions & 0 deletions components/molecules/VCarouselProgress/VCarouselProgress.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { ThemeProps } from '~/composables/use-theme'
const props = defineProps<{
index: number
snapLength: number
} & ThemeProps>()
const { themeClass } = useTheme({ props })
const root = ref<HTMLElement | null>(null)
const thumb = ref<HTMLElement | null>(null)
onMounted(() => {
setIndicatorWidth()
setIndicatorPosition(props.index)
})
watch(() => props.snapLength, () => {
setIndicatorWidth()
setIndicatorPosition(props.index)
}, { flush: 'post' })
watch(() => props.index, (newIndex) => {
setIndicatorPosition(newIndex)
})
function setIndicatorWidth() {
if (!root.value?.offsetWidth || !thumb.value) return // can be undefined in SSR
const value = (root.value?.offsetWidth / Math.max(props.snapLength, 1) / root.value?.offsetWidth) * 100
thumb.value?.style.setProperty('--v-carousel-controls-thumb-width', value + '%')
}
function setIndicatorPosition(index: number) {
if (!root.value || !thumb.value) return
const percent = index / Math.max(props.snapLength - 1, 1)
const translate = percent * (root.value?.offsetWidth - thumb.value?.getBoundingClientRect().width)
thumb.value.style.translate = translate + 'px 0 '
}
</script>

<template>
<div
ref="root"
:class="[$style.root, themeClass]"
>
<div
ref="thumb"
:class="$style.thumb"
/>
</div>
</template>

<style lang="scss" module>
@use 'assets/scss/functions/rem' as *;
@use 'assets/scss/mixins/include-media' as *;
@use 'assets/scss/mixins/theme' as *;
.root {
position: relative;
overflow: hidden;
width: rem(48);
height: rem(2);
flex-shrink: 0;
@include theme-variants('colors-control');
&::before {
position: absolute;
background-color: var(--theme-colors-control-content-disabled, #ccc);
content: '';
inset: 0;
}
@include media('>=lg') {
width: rem(64);
}
}
.thumb {
position: relative;
width: clamp(#{rem(22)}, var(--v-carousel-controls-thumb-width), 100%);
height: 100%;
background-color: var(--theme-colors-control-content, currentColor);
content: '';
transform-origin: left;
transition: 0.2s linear, 0.5s;
transition-property: translate, width;
}
</style>
60 changes: 60 additions & 0 deletions composables/use-carousel-controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Ref } from 'vue'

export interface VCarouselControlsOptions {
displayNumbers?: boolean
snapLength: Ref<number>
index: Ref<number>
}

function formatValue(n: number) {
return (n < 9 ? '0' : '') + (n + 1)
}

export function useCarouselControls(options: VCarouselControlsOptions) {
const { t } = useI18n()

const numbersOutput = computed(() => {
if (!options.displayNumbers) return

return `${formatValue(toValue(options.index))} / ${formatValue(toValue(options.snapLength) - 1)}`
})

function onButtonClicked(event: Event) {
const el = event.currentTarget as HTMLButtonElement

if (el.name === 'next') options.index.value = toValue(options.index) + 1
else if (el.name === 'previous') options.index.value = toValue(options.index) - 1
}

const isCarouselDraggable = computed(() => {
const length = toValue(options.snapLength)
return !!length && length > 1
})

const nextDisabled = computed(() => {
return (toValue(options.index) === toValue(options.snapLength) - 1) || !toValue(options.snapLength)
})

const nextButtonAttrs = computed(() => {
return {
disabled: nextDisabled.value,
name: 'next',
iconName: 'arrow-right',
ariaLabel: t('carousel.next_slide_aria'),
onClick: onButtonClicked,
}
})

const previousDisabled = computed(() => toValue(options.index) === 0)
const prevButtonAttrs = computed(() => {
return {
disabled: previousDisabled.value,
name: 'previous',
iconName: 'arrow-left',
ariaLabel: t('carousel.previous_slide_aria'),
onClick: onButtonClicked,
}
})

return { numbersOutput, onButtonClicked, nextButtonAttrs, prevButtonAttrs, previousDisabled, nextDisabled, isCarouselDraggable }
}

0 comments on commit 974a1a5

Please sign in to comment.