Skip to content
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import OcNotificationMessage from './OcNotificationMessage.vue'
import { defaultPlugins, mount } from '@opencloud-eu/web-test-helpers'
import { nextTick } from 'vue'

describe('OcNotificationMessage', () => {
beforeEach(() => {
Expand Down Expand Up @@ -91,6 +92,59 @@ describe('OcNotificationMessage', () => {
expect(wrapper.emitted('close')).toBeTruthy()
})

it('does not close if the timeout is 0', () => {
const wrapper = getWrapper({ timeout: 0 })

vi.advanceTimersByTime(60000)
expect(wrapper.emitted('close')).toBeFalsy()
})

describe('timeout while the user interacts with the message', () => {
let wrapper: ReturnType<typeof getWrapper>

beforeEach(async () => {
wrapper = getWrapper({ timeout: 1 }, document.body)
await nextTick()
})

afterEach(() => {
wrapper.unmount()
})

it('does not close while the pointer is over the message', async () => {
await wrapper.trigger('mouseenter')
vi.advanceTimersByTime(5000)
expect(wrapper.emitted('close')).toBeFalsy()

await wrapper.trigger('mouseleave')
vi.advanceTimersByTime(1000)
expect(wrapper.emitted('close')).toBeTruthy()
})

it('does not close while focus is inside the message', async () => {
const outside = document.body.appendChild(document.createElement('button'))

wrapper.find('button').element.focus()
await nextTick()
vi.advanceTimersByTime(5000)
expect(wrapper.emitted('close')).toBeFalsy()

outside.focus()
await nextTick()
vi.advanceTimersByTime(1000)
expect(wrapper.emitted('close')).toBeTruthy()
outside.remove()
})

it('does not close when the pointer leaves while focus is still inside', async () => {
wrapper.find('button').element.focus()
await wrapper.trigger('mouseenter')
await wrapper.trigger('mouseleave')
vi.advanceTimersByTime(5000)
expect(wrapper.emitted('close')).toBeFalsy()
})
})

const selectors = {
messageTitle: '.oc-notification-message-title',
messageContent: '.oc-notification-message-content',
Expand All @@ -99,8 +153,9 @@ describe('OcNotificationMessage', () => {
errorLogToggleButton: '.oc-notification-message-error-log-toggle-button'
}

function getWrapper(props = {}) {
function getWrapper(props = {}, attachTo?: HTMLElement) {
return mount(OcNotificationMessage, {
attachTo,
props: {
...props,
title: 'Test passed'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<div
ref="rootRef"
class="flex flex-wrap oc-notification-message shadow-sm/10 rounded-xl border break-keep bg-role-surface motion-safe:animate-fade-in relative"
:class="classes"
>
Expand Down Expand Up @@ -39,7 +40,8 @@
</template>

<script setup lang="ts">
import { ref, computed, onMounted, unref } from 'vue'
import { ref, computed, unref, useTemplateRef, watch } from 'vue'
import { useElementHover, useFocusWithin, useTimeoutFn } from '@vueuse/core'
import OcIcon from '../OcIcon/OcIcon.vue'
import OcButton from '../OcButton/OcButton.vue'
import OcErrorLog from '../OcErrorLog/OcErrorLog.vue'
Expand Down Expand Up @@ -100,6 +102,7 @@ const {
const emit = defineEmits<Emits>()
defineSlots<Slots>()

const rootRef = useTemplateRef<HTMLElement>('rootRef')
const showErrorLog = ref(false)

const classes = computed(() => `oc-notification-message-${status}`)
Expand All @@ -111,13 +114,20 @@ const close = () => {
emit('close')
}

onMounted(() => {
if (timeout !== 0) {
setTimeout(() => {
close()
}, timeout * 1000)
}
})
const isHovered = useElementHover(rootRef)
const { focused } = useFocusWithin(rootRef)
const { start, stop } = useTimeoutFn(close, timeout * 1000, { immediate: false })

watch(
[isHovered, focused],
([hovered, isFocused]) => {
stop()
if (timeout !== 0 && !hovered && !isFocused) {
start()
}
},
{ immediate: true }
)
</script>
<style scoped>
@reference '@opencloud-eu/design-system/tailwind';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {
createRouter,
defaultPlugins,
mount,
PartialComponentProps,
RouteLocation,
shallowMount
} from '@opencloud-eu/web-test-helpers'
import { nextTick } from 'vue'

import Pagination from './OcPagination.vue'
import OcPagination from './OcPagination.vue'
Expand Down Expand Up @@ -94,6 +97,34 @@ describe('OcPagination', () => {
expect(wrapper.find(selectors.listItemEllipsis).exists()).toBeTruthy()
})

it('does not repeat the first and last page if they are already in the displayed range', async () => {
const wrapper = await getRoutedWrapper({ pages: 6, currentPage: 3, maxDisplayed: 4 })

expect(getDisplayedPages(wrapper)).toEqual(['1', '2', '3', '4', '5', '6'])
wrapper.unmount()
})

it.each([
{ pages: 6, maxDisplayed: 3 },
{ pages: 6, maxDisplayed: 4 },
{ pages: 10, maxDisplayed: 4 },
{ pages: 10, maxDisplayed: 5 }
])(
'shows every page once, including the first and last one ($pages pages, maxDisplayed $maxDisplayed)',
async ({ pages, maxDisplayed }) => {
for (let currentPage = 1; currentPage <= pages; currentPage++) {
const wrapper = await getRoutedWrapper({ pages, currentPage, maxDisplayed })
const displayedPages = getDisplayedPages(wrapper).filter((page) => page !== '...')

expect(new Set(displayedPages).size).toBe(displayedPages.length)
expect(displayedPages).toContain('1')
expect(displayedPages).toContain(pages.toString())
expect(displayedPages).toContain(currentPage.toString())
wrapper.unmount()
}
}
)

it("doesn't show ellipsis if maxDisplayed prop is set but no pages are removed", () => {
const wrapper = getWrapper({ maxDisplayed: 3 })

Expand All @@ -113,8 +144,87 @@ describe('OcPagination', () => {
expect((prevItem as RouteLocationPathRaw).query?.page).toBe(2)
expect((nextItem as RouteLocationPathRaw).query?.page).toBe(4)
})

describe('accessibility', () => {
it('marks only the current page as current, not the links to other pages', async () => {
const wrapper = await getRoutedWrapper()

const current = wrapper.findAll('[aria-current]')
expect(current.length).toBe(1)
expect(current[0].text()).toBe('3')
wrapper.unmount()
})

it('moves focus to the new current page when the activated link is replaced by it', async () => {
const wrapper = await getRoutedWrapper()
const link = wrapper.findAll(selectors.listItemLink).find((l) => l.text() === '4')
;(link.element as HTMLElement).focus()
const focusSpy = vi.spyOn(HTMLElement.prototype, 'focus')

await wrapper.setProps({ currentPage: 4 })
await nextTick()

expect(document.activeElement).toBe(wrapper.find(selectors.listItemCurrent).element)
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
focusSpy.mockRestore()
wrapper.unmount()
})

it('moves focus to the new current page when the displayed pages shift', async () => {
const wrapper = await getRoutedWrapper({ pages: 10, currentPage: 5, maxDisplayed: 3 })
const link = wrapper.findAll(selectors.listItemLink).find((l) => l.text() === '4')
;(link.element as HTMLElement).focus()

await wrapper.setProps({ currentPage: 4 })
await nextTick()

const current = wrapper.find(selectors.listItemCurrent)
expect(current.text()).toBe('4')
expect(document.activeElement).toBe(current.element)
wrapper.unmount()
})

it('keeps focus on the activated link if it is still there', async () => {
const wrapper = await getRoutedWrapper()
const next = wrapper.find(selectors.listItemNext).element as HTMLElement
next.focus()

await wrapper.setProps({ currentPage: 4 })
await nextTick()

expect(document.activeElement).toBe(next)
wrapper.unmount()
})

it('does not take focus when the page changes from outside the pagination', async () => {
const wrapper = await getRoutedWrapper()

await wrapper.setProps({ currentPage: 4 })
await nextTick()

expect(document.activeElement).toBe(document.body)
wrapper.unmount()
})
})
})

async function getRoutedWrapper(props: PartialComponentProps<typeof OcPagination> = {}) {
const router = createRouter({
routes: [{ path: '/files', name: 'files', component: { template: '<div />' } }]
})
await router.push({ name: 'files', query: { page: 3 } })

return mount(Pagination, {
props: { ...defaultProps, ...props, currentRoute: router.currentRoute.value },
attachTo: document.body,
global: { plugins: [...defaultPlugins(), router], stubs: { 'oc-icon': true } }
})
}

function getDisplayedPages(wrapper: Awaited<ReturnType<typeof getRoutedWrapper>>) {
return wrapper.findAll(selectors.listItemPage).map((page) => page.text())
}

function getWrapper(props: PartialComponentProps<typeof OcPagination> = {}) {
return shallowMount(Pagination, {
props: { ...defaultProps, ...props },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
<template>
<nav class="oc-pagination" :aria-label="$gettext('Pagination')">
<ol class="oc-pagination-list flex items-center flex-wrap m-0 gap-2">
<ol ref="listRef" class="oc-pagination-list flex items-center flex-wrap m-0 gap-2">
<li v-if="isPrevPageAvailable" class="oc-pagination-list-item">
<component
:is="routerLinkComponent"
class="oc-pagination-list-item-prev flex mr-2 rounded-sm hover:bg-role-secondary hover:text-role-on-secondary [&_svg]:hover:!fill-role-on-secondary"
:aria-label="$gettext('Go to the previous page')"
:aria-current="null"
:to="previousPageLink"
>
<oc-icon name="arrow-drop-left" fill-type="line" color="var(--oc-role-on-surface)" />
</component>
</li>
<li v-for="(page, index) in displayedPages" :key="index" class="oc-pagination-list-item">
<li
v-for="(page, index) in displayedPages"
:key="typeof page === 'number' ? page : `ellipsis-${index}`"
class="oc-pagination-list-item"
>
<component
:is="pageComponent(page)"
class="oc-pagination-list-item-page py-1 px-2 rounded-sm hover:bg-role-secondary hover:text-role-on-secondary transition-colors duration-200 ease-in-out"
:class="pageClass(page)"
v-bind="bindPageProps(page)"
:aria-current="isCurrentPage(page) ? 'page' : null"
>
{{ page }}
</component>
Expand All @@ -26,6 +32,7 @@
:is="routerLinkComponent"
class="oc-pagination-list-item-next flex ml-2 rounded-sm hover:bg-role-secondary [&_svg]:hover:!fill-role-on-secondary"
:aria-label="$gettext('Go to the next page')"
:aria-current="null"
:to="nextPageLink"
>
<oc-icon name="arrow-drop-right" fill-type="line" color="var(--oc-role-on-surface)" />
Expand All @@ -36,7 +43,7 @@
</template>

<script setup lang="ts">
import { computed, unref } from 'vue'
import { computed, nextTick, unref, useTemplateRef, watch } from 'vue'
import OcIcon from '../OcIcon/OcIcon.vue'
import { useGettext } from 'vue3-gettext'
import { RouteLocation } from 'vue-router'
Expand Down Expand Up @@ -76,6 +83,7 @@ const {
} = defineProps<Props>()

const { $gettext } = useGettext()
const listRef = useTemplateRef<HTMLElement>('listRef')

const displayedPages = computed(() => {
let pagesCollection: Array<Page> = []
Expand All @@ -93,13 +101,13 @@ const displayedPages = computed(() => {
currentPageIndex + indentation + 1
)

if (unref($_currentPage) > 2) {
if (Number(pagesCollection[0]) > 1) {
Number(pagesCollection[0]) > 2
? pagesCollection.unshift(1, '...')
: pagesCollection.unshift(1)
}

if (unref($_currentPage) < pages - 1) {
if (Number(pagesCollection[pagesCollection.length - 1]) < pages) {
Number(pagesCollection[pagesCollection.length - 1]) < pages - 1
? pagesCollection.push('...', pages)
: pagesCollection.push(pages)
Expand Down Expand Up @@ -136,7 +144,7 @@ const bindPageProps = (page: Page) => {

if (isCurrentPage(page)) {
return {
'aria-current': 'page'
tabindex: -1
}
}

Expand Down Expand Up @@ -176,4 +184,20 @@ const bindPageLink = (page: Page) => {
params: currentRoute.params
}
}

watch(
() => currentPage,
async () => {
if (!unref(listRef)?.contains(document.activeElement)) {
return
}
await nextTick()
// the activated link may have been replaced by the new current page, which drops focus to the body
if (!unref(listRef)?.contains(document.activeElement)) {
unref(listRef)
?.querySelector<HTMLElement>('[aria-current="page"]')
?.focus({ preventScroll: true })
}
}
)
</script>
13 changes: 13 additions & 0 deletions packages/web-runtime/src/components/UploadInfo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
<oc-icon v-else-if="item.status === 'cancelled'" name="close" size-class="size-4" />
<oc-icon v-else-if="uploadsPaused" name="pause" size-class="size-4" />
<div v-else class="flex"><oc-spinner size="small" /></div>
<span class="upload-info-item-status sr-only" v-text="getUploadItemStatus(item)" />
<resource-list-item
v-if="displayFileAsResource(item)"
:key="item.path"
Expand Down Expand Up @@ -583,6 +584,18 @@ function retryUploads() {
uppyService.retryAllUploads()
}

function getUploadItemStatus(item: UploadResult) {
switch (item.status) {
case 'error':
return $gettext('Failed')
case 'success':
return $gettext('Uploaded')
case 'cancelled':
return $gettext('Cancelled')
}
return unref(uploadsPaused) ? $gettext('Paused') : $gettext('Uploading')
}

function getUploadItemMessage(item: UploadResult) {
const error = unref(errors)[item.meta.uploadId]

Expand Down
Loading
Loading