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 @@ + + + +### General description + +This component renders a card: a bordered box with a heading, an optional icon in front of it, actions at the end of the heading row, an optional description, content and a footer. Cards are meant to be shown side by side in a grid, for example one card per item of an overview. + +The heading is an `h3` by default, one level below the `h2` of a page header. Set `heading-level` to the level that fits where the cards are shown. + +With `href` or `to` the heading becomes a link that covers the whole card, so the whole card can be clicked, or opened with `Enter` or `Space`, while it is announced to assistive technology as a single link with the name of the card. Set `target` to open it in another tab or window. Links, buttons and other controls in the `actions` slot, the content and the footer stay clickable on their own. As the rest of the content is part of the link, its text cannot be selected. The `active` prop highlights a card, for example the selected one, and marks its link as the current one for assistive technology. + +### Usage + +```vue + + + + + +``` + +### Link and active cards + +```vue + + + + + +``` + + + + + + + diff --git a/src/components/NcCard/index.ts b/src/components/NcCard/index.ts new file mode 100644 index 0000000000..850f2cd75c --- /dev/null +++ b/src/components/NcCard/index.ts @@ -0,0 +1,5 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +export { default } from './NcCard.vue' diff --git a/src/components/index.ts b/src/components/index.ts index 48afc3dc1f..4060b6a480 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -43,6 +43,7 @@ export { default as NcBlurHash } from './NcBlurHash/index.ts' export { default as NcBreadcrumb } from './NcBreadcrumb/index.js' export { default as NcBreadcrumbs } from './NcBreadcrumbs/index.js' export { default as NcButton } from './NcButton/index.ts' +export { default as NcCard } from './NcCard/index.ts' export { default as NcCheckboxRadioSwitch } from './NcCheckboxRadioSwitch/index.js' export { default as NcChip } from './NcChip/index.ts' export { default as NcCollectionList } from './NcCollectionList/index.js' diff --git a/styleguide.config.cjs b/styleguide.config.cjs index 81c1909adc..060c240a28 100644 --- a/styleguide.config.cjs +++ b/styleguide.config.cjs @@ -357,6 +357,11 @@ module.exports = async () => { url: 'https://github.com/nextcloud-libraries/nextcloud-vue', }, + styleguideComponents: { + // Its own name, so it no longer shares a cached stylesheet with the props table + 'Markdown/Table/TableRenderer$': path.join(__dirname, 'styleguide/components/MarkdownTableRenderer.js'), + }, + // see https://github.com/styleguidist/react-styleguidist/blob/master/src/client/styles/theme.js theme: { maxWidth: '900px', diff --git a/styleguide/components/MarkdownTableRenderer.js b/styleguide/components/MarkdownTableRenderer.js new file mode 100644 index 0000000000..74029545d1 --- /dev/null +++ b/styleguide/components/MarkdownTableRenderer.js @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { TableRenderer } from 'rsg-components-default/Markdown/Table/TableRenderer' +import Styled from 'rsg-components/Styled' + +/** + * The table of the Markdown documentation, under its own name. + * + * Styleguidist caches a stylesheet by the name of the component it styles. The + * Markdown table and the props, events and slots table are both called + * `TableRenderer`, so they shared one sheet: whichever rendered first in a + * session styled both, and a page with a table in its documentation left every + * props table after it without padding, borders or column widths. + * + * @param {object} props The props of the Markdown table + */ +function MarkdownTableRenderer(props) { + return TableRenderer(props) +} + +/** + * The styles of the original Markdown table. + * + * @param {object} theme The styleguide theme + * @param {number[]} theme.space The spacing scale + */ +function styles({ space }) { + return { + table: { + marginTop: 0, + marginBottom: space[2], + borderCollapse: 'collapse', + }, + } +} + +export default Styled(styles)(MarkdownTableRenderer) diff --git a/tests/component/components/NcCard.spec.ts b/tests/component/components/NcCard.spec.ts new file mode 100644 index 0000000000..8ea061c79d --- /dev/null +++ b/tests/component/components/NcCard.spec.ts @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mdiPaletteOutline } from '@mdi/js' +import { expect, test } from '@playwright/experimental-ct-vue' +import NcCard from '../../../src/components/NcCard/NcCard.vue' + +test('follows the link from the empty space of the content and the footer, but not from their controls', async ({ mount, page }) => { + const card = await mount(NcCard, { + props: { name: 'Design', href: '#followed', style: 'width: 400px' }, + slots: { + default: '

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: 'Published', + }, + }) + 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) + }) +})