+ {{ description }} +
+diff --git a/src/components/NcCard/NcCard.vue b/src/components/NcCard/NcCard.vue
new file mode 100644
index 0000000000..4dbffac9ff
--- /dev/null
+++ b/src/components/NcCard/NcCard.vue
@@ -0,0 +1,396 @@
+
+
+ Last changed by Jane Doe.
+ {{ description }}
+
Six members
', + footer: '', + }, + }) + + // The end of the content row, right of its text + const content = (await card.locator('.content').boundingBox())! + await page.mouse.click(content.x + content.width - 10, content.y + content.height / 2) + await expect(page).toHaveURL(/#followed$/) + + await page.evaluate(() => history.replaceState(null, '', '#')) + await card.locator('.footer-button').click() + await expect(page).not.toHaveURL(/#followed$/) +}) + +for (const key of ['Enter', ' ']) { + test(`follows the link with ${key === ' ' ? 'Space' : key}`, async ({ mount, page }) => { + await mount(NcCard, { props: { name: 'Design', href: '#followed' } }) + await page.keyboard.press('Tab') + await expect(page.getByRole('link', { name: 'Design' })).toBeFocused() + await page.keyboard.press(key) + await expect(page).toHaveURL(/#followed$/) + }) +} + +test('lines up the icon with the description below it', async ({ mount }) => { + const card = await mount(NcCard, { + props: { name: 'Design', description: 'The design team', iconPath: mdiPaletteOutline, style: 'width: 400px' }, + }) + const [icon, description] = await Promise.all([ + card.locator('svg').boundingBox(), + card.getByText('The design team').boundingBox(), + ]) + expect(icon!.x).toBeCloseTo(description!.x, 0) +}) + +test('marks the active card in forced colors', async ({ mount, page }) => { + await page.emulateMedia({ forcedColors: 'active' }) + const card = await mount(NcCard, { props: { name: 'Design', active: true } }) + const highlight = await page.evaluate(() => { + const probe = document.createElement('div') + probe.style.color = 'Highlight' + document.body.append(probe) + return getComputedStyle(probe).color + }) + await expect(card).toHaveCSS('border-top-color', highlight) + await expect(card).toHaveCSS('outline-color', highlight) +}) + +test('rounds the corners of the clickable area like the card', async ({ mount }) => { + const card = await mount(NcCard, { props: { name: 'Design', href: '#' } }) + const radius = await card.locator('a').evaluate((element) => getComputedStyle(element, '::after').borderTopLeftRadius) + expect(radius).not.toBe('0px') + await expect(card).toHaveCSS('border-top-left-radius', radius) +}) diff --git a/tests/unit/components/NcCard/NcCard.spec.ts b/tests/unit/components/NcCard/NcCard.spec.ts new file mode 100644 index 0000000000..77a1c1929d --- /dev/null +++ b/tests/unit/components/NcCard/NcCard.spec.ts @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { createRouter, createWebHashHistory } from 'vue-router' +import NcCard from '../../../../src/components/NcCard/NcCard.vue' + +describe('NcCard', () => { + it('renders an article with the name as a level 3 heading by default', () => { + const wrapper = mount(NcCard, { props: { name: 'Meeting notes' } }) + expect(wrapper.element.tagName).toBe('ARTICLE') + expect(wrapper.get('h3').text()).toBe('Meeting notes') + expect(wrapper.find('a').exists()).toBe(false) + }) + + it('renders the heading at the given level, but never as h1', () => { + const wrapper = mount(NcCard, { props: { name: 'Meeting notes', headingLevel: 2 } }) + expect(wrapper.get('h2').text()).toBe('Meeting notes') + + // @ts-expect-error -- level 1 is not part of the type, but can still be passed at runtime + const level1 = mount(NcCard, { props: { name: 'Meeting notes', headingLevel: 1 } }) + expect(level1.find('h1').exists()).toBe(false) + expect(level1.get('h2').text()).toBe('Meeting notes') + }) + + it('renders the description only when it is set', () => { + const without = mount(NcCard, { props: { name: 'Meeting notes' } }) + expect(without.find('p').exists()).toBe(false) + + const withDescription = mount(NcCard, { props: { name: 'Meeting notes', description: 'Weekly notes' } }) + expect(withDescription.get('p').text()).toBe('Weekly notes') + }) + + it('renders the icon only when an icon path or icon slot is given', () => { + const without = mount(NcCard, { props: { name: 'Budget' } }) + expect(without.find('svg').exists()).toBe(false) + + const withPath = mount(NcCard, { props: { name: 'Budget', iconPath: 'M0 0h24v24H0z' } }) + expect(withPath.find('svg path').attributes('d')).toBe('M0 0h24v24H0z') + + const withSlot = mount(NcCard, { + props: { name: 'Budget', iconPath: 'M0 0h24v24H0z' }, + slots: { icon: '' }, + }) + expect(withSlot.find('.custom-icon').exists()).toBe(true) + expect(withSlot.find('svg').exists()).toBe(false) + }) + + it('renders the actions, the content and the footer slots', () => { + const wrapper = mount(NcCard, { + props: { name: 'Budget' }, + slots: { + actions: '', + default: 'Last changed by Jane Doe.
', + footer: '', + }, + }) + expect(wrapper.get('button').text()).toBe('Rename') + expect(wrapper.get('.content').text()).toBe('Last changed by Jane Doe.') + expect(wrapper.get('.footer').text()).toBe('Published') + }) + + it('turns the heading into a link when href is set', () => { + const wrapper = mount(NcCard, { props: { name: 'Design', href: '/teams/design' } }) + const link = wrapper.get('h3 a') + expect(link.attributes('href')).toBe('/teams/design') + expect(link.text()).toBe('Design') + }) + + it('keeps the aria-current of a router link to the current route', async () => { + const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', name: 'root', component: { template: '' } }, + { path: '/teams/:id', name: 'team', component: { template: '' } }, + ], + }) + await router.push({ name: 'team', params: { id: 'design' } }) + const wrapper = mount(NcCard, { + props: { name: 'Design', to: { name: 'team', params: { id: 'design' } } }, + global: { plugins: [router] }, + }) + expect(wrapper.get('h3 a').attributes('aria-current')).toBe('page') + }) + + it('turns the heading into a router link when to is set and a router is available', async () => { + const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', name: 'root', component: { template: '' } }, + { path: '/teams/:id', name: 'team', component: { template: '' } }, + ], + }) + const wrapper = mount(NcCard, { + props: { name: 'Design', to: { name: 'team', params: { id: 'design' } }, href: '/ignored' }, + global: { plugins: [router] }, + }) + await router.isReady() + expect(wrapper.get('h3 a').attributes('href')).toBe('#/teams/design') + }) + + it('opens the link in the target, without access to this page', () => { + const wrapper = mount(NcCard, { props: { name: 'Design', href: 'https://example.com', target: '_blank' } }) + const link = wrapper.get('h3 a') + expect(link.attributes('target')).toBe('_blank') + expect(link.attributes('rel')).toBe('nofollow noreferrer noopener') + }) + + it('marks the link of an active card as the current one', () => { + const inactive = mount(NcCard, { props: { name: 'Design', href: '/teams/design' } }) + expect(inactive.get('h3 a').attributes('aria-current')).toBeUndefined() + + const active = mount(NcCard, { props: { name: 'Design', href: '/teams/design', active: true } }) + expect(active.get('h3 a').attributes('aria-current')).toBe('true') + }) + + it('falls back to href when to is set without a router', () => { + const wrapper = mount(NcCard, { props: { name: 'Design', to: { name: 'team' }, href: '/teams/design' } }) + expect(wrapper.get('h3 a').attributes('href')).toBe('/teams/design') + }) + + it('adds the active class only when active', () => { + const inactive = mount(NcCard, { props: { name: 'Design' } }) + expect(inactive.classes().some((name) => name.includes('ncCard_active'))).toBe(false) + + const active = mount(NcCard, { props: { name: 'Design', active: true } }) + expect(active.classes().some((name) => name.includes('ncCard_active'))).toBe(true) + }) +})