Skip to content
Open
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
13 changes: 13 additions & 0 deletions playgrounds/nuxt/app/pages/components/marquee.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,35 @@ const orientations = Object.keys(theme.variants.orientation)

const orientation = ref('horizontal' as keyof typeof theme.variants.orientation)
const pauseOnHover = ref(false)
const pauseOnTouch = ref(false)
const reverse = ref(false)
const overlay = ref(false)
const repeat = ref(3)
const duration = ref(20)
const pauseOnClick = ref(false)
</script>

<template>
<Navbar>
<USelect v-model="orientation" :items="orientations" />
<USwitch v-model="pauseOnHover" label="Pause" />
<USwitch v-model="pauseOnTouch" label="Pause on Touch" />
<USwitch v-model="pauseOnClick" label="Pause on Click" />
<USwitch v-model="reverse" label="Reverse" />
<USwitch v-model="overlay" label="Overlay" />
<UInputNumber v-model="repeat" :min="1" :max="10" />
<UInputNumber v-model="duration" :min="1" :max="100" />
</Navbar>

<UMarquee
:orientation="orientation"
:pause-on-hover="pauseOnHover"
:pause-on-touch="pauseOnTouch"
:pause-on-click="pauseOnClick"
:reverse="reverse"
:overlay="overlay"
:repeat="repeat"
:duration="duration"
class="data-[orientation=horizontal]:w-lg"
>
<UIcon name="simple-icons:github" class="size-10 shrink-0" />
Expand All @@ -30,5 +42,6 @@ const overlay = ref(false)
<UIcon name="simple-icons:instagram" class="size-10 shrink-0" />
<UIcon name="simple-icons:linkedin" class="size-10 shrink-0" />
<UIcon name="simple-icons:facebook" class="size-10 shrink-0" />
<UButton label="Click Me" color="neutral" variant="subtle" size="xs" @click="console.log('Button clicked!')" />
</UMarquee>
</template>
68 changes: 64 additions & 4 deletions src/runtime/components/Marquee.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export interface MarqueeProps {
* @defaultValue false
*/
pauseOnHover?: boolean
/**
* Pause the marquee on touch.
* @defaultValue false
*/
pauseOnTouch?: boolean
/**
* Reverse the direction of the marquee.
* @defaultValue false
Expand All @@ -36,6 +41,16 @@ export interface MarqueeProps {
* @defaultValue true
*/
overlay?: boolean
/**
* The duration of the marquee animation in seconds.
* @defaultValue 20
*/
duration?: number
/**
* Pause the marquee when clicking on it.
* @defaultValue false
*/
pauseOnClick?: boolean
class?: any
ui?: Marquee['slots']
}
Expand All @@ -46,32 +61,77 @@ export interface MarqueeSlots {
</script>

<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { Primitive } from 'reka-ui'
import { onClickOutside } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { tv } from '../utils/tv'

const props = withDefaults(defineProps<MarqueeProps>(), {
orientation: 'horizontal',
repeat: 4,
overlay: true
overlay: true,
duration: 20,
pauseOnClick: false
})
defineSlots<MarqueeSlots>()

const appConfig = useAppConfig() as Marquee['AppConfig']

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.marquee || {}) })({
pauseOnHover: props.pauseOnHover,
pauseOnTouch: props.pauseOnTouch,
orientation: props.orientation,
reverse: props.reverse,
overlay: props.overlay
}))

const container = ref<HTMLElement | null>(null)
const paused = ref(false)

onClickOutside(container, () => {
if (props.pauseOnClick) {
paused.value = false
}
})

function togglePause(e: MouseEvent) {
if (!props.pauseOnClick) {
return
}

const target = e.target as HTMLElement
if (target.closest('button, a, input, textarea, select')) {
return
}

paused.value = !paused.value
}
</script>

<template>
<Primitive :as="as" :data-orientation="orientation" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<div v-for="i in repeat" :key="i" data-slot="content" :class="ui.content({ class: [props.ui?.content] })">
<Primitive
ref="container"
:as="as"
:data-orientation="orientation"
data-slot="root"
:class="ui.root({ class: [props.ui?.root, props.class, pauseOnClick && !paused && 'cursor-pointer'] })"
:style="{ '--duration': `${props.duration}s` }"
@click="togglePause"
>
<div
v-for="i in repeat"
:key="i"
:aria-hidden="i > 1 ? 'true' : undefined"
data-slot="content"
:class="ui.content({ class: [props.ui?.content] })"
:style="paused ? { animationPlayState: 'paused' } : undefined"
>
<slot />
</div>
<div
v-if="pauseOnClick && !paused"
:class="ui.overlay({ class: [props.ui?.overlay] })"
/>
</Primitive>
</template>
10 changes: 8 additions & 2 deletions src/theme/marquee.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export default {
slots: {
root: 'group relative flex items-center overflow-hidden gap-(--gap) [--gap:--spacing(16)] [--duration:20s]',
content: 'flex items-center shrink-0 justify-around gap-(--gap) min-w-max'
root: 'group relative flex items-center overflow-hidden gap-(--gap) [--gap:--spacing(16)] motion-reduce:animate-none',
content: 'flex items-center shrink-0 justify-around gap-(--gap) min-w-max relative z-0',
overlay: 'absolute inset-0 z-10 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity'
},
variants: {
orientation: {
Expand All @@ -17,6 +18,11 @@ export default {
content: 'group-hover:[animation-play-state:paused]'
}
},
pauseOnTouch: {
true: {
content: 'group-active:[animation-play-state:paused]'
}
},
reverse: {
true: {
content: '![animation-direction:reverse]'
Expand Down
56 changes: 56 additions & 0 deletions test/components/Marquee.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { axe } from 'vitest-axe'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { h } from 'vue'
import Marquee from '../../src/runtime/components/Marquee.vue'
import type { MarqueeProps, MarqueeSlots } from '../../src/runtime/components/Marquee.vue'
import ComponentRender from '../component-render'
Expand All @@ -23,6 +24,61 @@ describe('Marquee', () => {
expect(html).toMatchSnapshot()
})

it('handles pauseOnClick correctly', async () => {
const wrapper = await mountSuspended(Marquee, {
props: {
pauseOnClick: true,
duration: 30
},
slots: {
default: () => 'Content'
}
})

// Check duration style
expect(wrapper.attributes('style')).toContain('--duration: 30s')

// Initial state: not paused
expect(wrapper.find('[data-slot="content"]').attributes('style')).toBeUndefined()

// Check overlay exists and is visible (conditionally based on implementation, here check class or existence)
const overlay = wrapper.find('.absolute.inset-0.z-10')
expect(overlay.exists()).toBe(true)

// Click wrapper to pause
await wrapper.trigger('click')

// Paused state
expect(wrapper.find('[data-slot="content"]').attributes('style')).toContain('animation-play-state: paused')

// Overlay should be gone when paused
expect(wrapper.find('.absolute.inset-0.z-10').exists()).toBe(false)

// Click wrapper to resume
await wrapper.trigger('click')
expect(wrapper.find('[data-slot="content"]').attributes('style')).toBeUndefined()
})

it('prevents pause when clicking interactive elements', async () => {
const wrapper = await mountSuspended(Marquee, {
props: {
pauseOnClick: true,
// Reduce repeat to 1 to simplify finding
repeat: 1
},
slots: {
default: () => h('button', { id: 'btn' }, 'Click me')
}
})

const button = wrapper.find('#btn')
expect(button.exists()).toBe(true)
await button.trigger('click')

// Should NOT be paused
expect(wrapper.find('[data-slot="content"]').attributes('style')).toBeUndefined()
})

it('passes accessibility tests', async () => {
const wrapper = await mountSuspended(Marquee, {
slots: {
Expand Down
1 change: 1 addition & 0 deletions test/components/Slideover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@ describe('Slideover', () => {
})

expect(await axe(wrapper.element)).toHaveNoViolations()
wrapper.unmount()
})
})
Loading
Loading