From 9cbb6663f0fbc01f33b0da3bb6d51690405ed053 Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Mon, 25 Jul 2022 10:43:22 +0300 Subject: [PATCH 1/8] feat: introduce Card component --- src/components/Card.vue | 323 +++++++++++++++++++ src/components/CardCover.vue | 79 +++++ src/constants/CardSize.js | 4 + src/styles/core/colors/_dark.scss | 2 + src/styles/core/colors/_light.scss | 5 + src/styles/core/typography/_font-styles.scss | 26 ++ tests/unit/components/Card.spec.js | 222 +++++++++++++ tests/unit/components/CardCover.spec.js | 77 +++++ 8 files changed, 738 insertions(+) create mode 100644 src/components/Card.vue create mode 100644 src/components/CardCover.vue create mode 100644 src/constants/CardSize.js create mode 100644 tests/unit/components/Card.spec.js create mode 100644 tests/unit/components/CardCover.spec.js diff --git a/src/components/Card.vue b/src/components/Card.vue new file mode 100644 index 000000000..a2bf954bc --- /dev/null +++ b/src/components/Card.vue @@ -0,0 +1,323 @@ + + + + + + + diff --git a/src/components/CardCover.vue b/src/components/CardCover.vue new file mode 100644 index 000000000..bc4e8184d --- /dev/null +++ b/src/components/CardCover.vue @@ -0,0 +1,79 @@ + + + + + + diff --git a/src/constants/CardSize.js b/src/constants/CardSize.js new file mode 100644 index 000000000..4b315b1ff --- /dev/null +++ b/src/constants/CardSize.js @@ -0,0 +1,4 @@ +export default { + small: 'small', + large: 'large', +}; diff --git a/src/styles/core/colors/_dark.scss b/src/styles/core/colors/_dark.scss index 04aa06825..13c547bc8 100644 --- a/src/styles/core/colors/_dark.scss +++ b/src/styles/core/colors/_dark.scss @@ -86,4 +86,6 @@ --color-syntax-urls: rgb(102, 153, 255); --color-tutorial-background: var(--color-fill-tertiary); --color-navigator-item-hover: #{change-color(dark-color(fill-blue), $alpha: 0.5)}; + + --color-card-shadow: rgb(255, 255, 255, 0.04); } diff --git a/src/styles/core/colors/_light.scss b/src/styles/core/colors/_light.scss index a32666ebc..e38f5514b 100644 --- a/src/styles/core/colors/_light.scss +++ b/src/styles/core/colors/_light.scss @@ -190,4 +190,9 @@ --color-tutorial-hero-text: #{dark-color(figure-gray)}; --color-tutorial-hero-background: #{dark-color(fill)}; --color-navigator-item-hover: #{change-color(light-color(fill-blue), $alpha: 0.05)}; + + --color-card-background: var(--color-fill); + --color-card-content-text: var(--color-figure-gray); + --color-card-eyebrow: var(--color-figure-gray-secondary-alt); + --color-card-shadow: rgb(0, 0, 0, 0.04); } diff --git a/src/styles/core/typography/_font-styles.scss b/src/styles/core/typography/_font-styles.scss index ac0e5d0b4..7072a5ea3 100644 --- a/src/styles/core/typography/_font-styles.scss +++ b/src/styles/core/typography/_font-styles.scss @@ -195,4 +195,30 @@ $font-styles: ( nav-menu-collapsible: ( large: (nav_14_14, nav) ), + card-eyebrow-small: ( + large: 14_18_semibold, + ), + card-eyebrow-large: ( + large: 17_21_semibold, + medium: 14_18_semibold, + small: 14_18_semibold, + ), + card-title-small: ( + large: 17_21_semibold, + medium: 14_18_semibold, + small: 17_21_semibold, + ), + card-title-large: ( + large: 21_25_semibold, + medium: 17_21_semibold, + small: 17_21_semibold, + ), + card-content-small: ( + large: 14_18, + ), + card-content-large: ( + large: 17_25, + medium: 14_18, + small: 14_18, + ), ) !default; diff --git a/tests/unit/components/Card.spec.js b/tests/unit/components/Card.spec.js new file mode 100644 index 000000000..c466c151d --- /dev/null +++ b/tests/unit/components/Card.spec.js @@ -0,0 +1,222 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2022 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors + */ + +import { shallowMount } from '@vue/test-utils'; +import Card from 'docc-render/components/Card.vue'; +import CardSize from 'docc-render/constants/CardSize'; +import Reference from 'docc-render/components/ContentNode/Reference.vue'; +import ContentNode from 'docc-render/components/ContentNode.vue'; +import { flushPromises } from '../../../test-utils'; + +const { + CardCover, ButtonLink, DiagonalArrowIcon, InlineChevronRightIcon, +} = Card.components; + +describe('Card', () => { + const image = { + identifier: 'identifier', + }; + + const abstract = [{ + type: 'text', + text: 'Abstract', + }]; + + const references = { + [image.identifier]: { + variants: [{ url: 'image.com', traits: ['1x'] }], + }, + }; + + const propsData = { + linkText: 'Watch', + image: image.identifier, + url: 'https://external.com', + title: 'Foo Bar', + eyebrow: 'eyebrow', + hasButton: false, + }; + + const provide = { references }; + + const mountCard = options => shallowMount(Card, { + propsData, + provide, + stubs: { + ContentNode, + }, + ...options, + }); + + const cardTitleIdRE = /card_title_[0-9]+/; + const cardEyebrowIdRE = /card_eyebrow_[0-9]+/; + const cardContentIdRE = /card_content_[0-9]+/; + + it('renders a .card root', () => { + const card = mountCard(); + expect(card.is('.card')).toBe(true); + expect(card.classes('ide')).toBe(false); + }); + + it('applies the correct AX tags', () => { + const card = mountCard(); + expect(card.attributes('aria-labelledby')).toMatch(/card_title_[0-9]+ card_eyebrow_[0-9]+/); + expect(card.attributes('aria-describedby')).toMatch(cardContentIdRE); + + const eyebrow = card.find('.eyebrow'); + expect(eyebrow.attributes('aria-label')).toBe(`- ${propsData.eyebrow}`); + expect(eyebrow.attributes('id')).toMatch(cardEyebrowIdRE); + + const title = card.find('.title'); + expect(title.attributes('id')).toMatch(cardTitleIdRE); + // not visible by default + expect(title.find('.visuallyhidden').exists()).toBe(false); + + expect(card.find('.details').attributes('aria-hidden')).toBe('true'); + }); + + it('renders `.link` with an `DiagonalArrowIcon` if external', () => { + const wrapper = mountCard({ + propsData: { + ...propsData, + showExternalLinks: true, + }, + }); + const icon = wrapper.find('.link .link-icon'); + expect(icon.exists()).toBeTruthy(); + expect(icon.is(DiagonalArrowIcon)).toBeTruthy(); + }); + + it('allows providing an AX helper text formatter, for external links', () => { + const wrapper = mountCard({ + propsData: { + ...propsData, + formatAriaLabel: label => `${label} (opens in browser)`, + }, + }); + expect(wrapper.find('.eyebrow').attributes('aria-label')).toMatch(/- .* \(opens in browser\)$/); + }); + + it('renders `.link` with a ChevronIcon for internal links', () => { + const wrapper = mountCard({ + propsData: { + ...propsData, + showExternalLinks: false, + hasButton: false, + }, + }); + const icon = wrapper.find('.link .link-icon'); + expect(icon.exists()).toBe(true); + expect(icon.is(InlineChevronRightIcon)).toBe(true); + // check if the special AX helper is visible for `references` + expect(wrapper.find('.title .visuallyhidden').exists()).toBe(false); + }); + + it('renders a `ButtonLink` if hasButton is true', () => { + const wrapper = mountCard({ + propsData: { + ...propsData, + hasButton: true, + }, + }); + const button = wrapper.find(ButtonLink); + expect(button.exists()).toBe(true); + expect(button.classes()).toEqual(['link']); + const icon = wrapper.find('.link .link-icon'); + expect(icon.exists()).toBe(false); + }); + + it('renders a `div` if hasButton is false', () => { + const wrapper = mountCard({ + propsData, + }); + const div = wrapper.find('.link'); + expect(div.exists()).toBe(true); + expect(div.is('div')).toBe(true); + }); + + it('renders no `.link`, if no `linkText` is provided', () => { + const wrapper = mountCard({ + propsData: { + ...propsData, + linkText: '', + }, + }); + expect(wrapper.find('.link').exists()).toBe(false); + }); + + it('renders a CardCover components', () => { + const wrapper = mountCard({ + propsData, + provide, + stubs: { + CardCover, + }, + }); + const cardCover = wrapper.find(CardCover); + expect(cardCover.exists()).toBe(true); + expect(cardCover.props()).toHaveProperty('variants', references[propsData.image].variants); + }); + + it('renders a `.link` with appropriate classes and aria label in WEB', () => { + const wrapper = mountCard(); + const link = wrapper.find('.link'); + + expect(link.attributes('aria-labelledby')).toBe(undefined); + expect(link.attributes('aria-describedby')).toBe(undefined); + }); + + it('renders a Reference component at the root', () => { + expect(mountCard().find(Reference).props('url')).toBe(propsData.url); + }); + + it('renders a .large or .small modifier depending on the size', () => { + const smallCard = mountCard({ propsData: { ...propsData, size: CardSize.small } }); + expect(smallCard.classes('small')).toBe(true); + expect(smallCard.classes('large')).toBe(false); + + const largeCard = mountCard({ propsData: { ...propsData, size: CardSize.large } }); + expect(largeCard.classes('small')).toBe(false); + expect(largeCard.classes('large')).toBe(true); + }); + + it('renders a title', () => { + const h3 = mountCard().find('.title'); + expect(h3.exists()).toBe(true); + expect(h3.text()).toBe(propsData.title); + }); + + it('renders a `ContentNode` when content is provided', async () => { + const content = [ + { + type: 'paragraph', + inlineContent: [{ type: 'text', text: 'hello world' }], + }, + ]; + const propsWithContent = { ...propsData, content }; + const wrapper = mountCard({ propsData: propsWithContent }); + await flushPromises(); + const node = wrapper.find(ContentNode); + expect(node.exists()).toBe(true); + expect(node.props('content')).toEqual(content); + expect(node.attributes('id')).toMatch(cardContentIdRE); + }); + + it('renders card as a `floatingStyle`', () => { + const wrapper = mountCard({ + propsData: { + ...propsData, + floatingStyle: true, + }, + }); + expect(wrapper.find(CardCover).props('rounded')).toBe(true); + expect(wrapper.classes()).toContain('floating-style'); + }); +}); diff --git a/tests/unit/components/CardCover.spec.js b/tests/unit/components/CardCover.spec.js new file mode 100644 index 000000000..462019141 --- /dev/null +++ b/tests/unit/components/CardCover.spec.js @@ -0,0 +1,77 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2022 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import { shallowMount } from '@vue/test-utils'; +import CardCover from 'docc-render/components/CardCover.vue'; + +const lightVariant = { + url: 'https://image.light', + size: { width: 42, height: 42 }, + traits: ['1x', '2x', 'light'], +}; +const darkVariant = { + url: 'https://image.dark', + size: { width: 42, height: 42 }, + traits: ['1x', '2x', 'dark'], +}; +const variants = [lightVariant, darkVariant]; + +const defaultProps = { + variants, +}; + +const mountCover = ({ propsData, ...rest } = {}) => { + const config = { + propsData: { + ...defaultProps, + ...propsData, + }, + ...rest, + }; + return shallowMount(CardCover, config); +}; + +describe('CardCover', () => { + it('renders images for light/dark backgrounds', () => { + const wrapper = mountCover(); + const covers = wrapper.findAll('.card-cover'); + expect(covers).toHaveLength(2); + expect(wrapper.find('.card-cover--light').attributes('style')) + .toContain(`background-image: url(${lightVariant.url});`); + expect(wrapper.find('.card-cover--dark').attributes('style')) + .toContain(`background-image: url(${darkVariant.url});`); + }); + + it('falls back to light image if dark variant is not available', () => { + const wrapper = mountCover({ + propsData: { + variants: [lightVariant], + }, + }); + expect(wrapper.find('.card-cover--dark').attributes('style')).toContain(lightVariant.url); + }); + + it('exposes a default slot', () => { + let slotProps = null; + const wrapper = mountCover({ + scopedSlots: { + default: (props) => { + slotProps = props; + return 'Slot Content'; + }, + }, + }); + expect(wrapper.find('.card-cover').exists()).toBe(false); + expect(slotProps).toEqual({ + classes: 'card-cover', + }); + expect(wrapper.text()).toBe('Slot Content'); + }); +}); From d53da60da85a30c43d16f6f2412977d050f8baeb Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Mon, 22 Aug 2022 19:23:19 +0300 Subject: [PATCH 2/8] chore: fix eslint issue --- src/constants/CardSize.js | 10 ++++++++++ tests/unit/components/Card.spec.js | 7 +------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/constants/CardSize.js b/src/constants/CardSize.js index 4b315b1ff..651f6febc 100644 --- a/src/constants/CardSize.js +++ b/src/constants/CardSize.js @@ -1,3 +1,13 @@ +/** + * This source file is part of the Swift.org open source project + * + * Copyright (c) 2022 Apple Inc. and the Swift project authors + * Licensed under Apache License v2.0 with Runtime Library Exception + * + * See https://swift.org/LICENSE.txt for license information + * See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + export default { small: 'small', large: 'large', diff --git a/tests/unit/components/Card.spec.js b/tests/unit/components/Card.spec.js index c466c151d..d36b42835 100644 --- a/tests/unit/components/Card.spec.js +++ b/tests/unit/components/Card.spec.js @@ -6,7 +6,7 @@ * * See https://swift.org/LICENSE.txt for license information * See https://swift.org/CONTRIBUTORS.txt for Swift project authors - */ +*/ import { shallowMount } from '@vue/test-utils'; import Card from 'docc-render/components/Card.vue'; @@ -24,11 +24,6 @@ describe('Card', () => { identifier: 'identifier', }; - const abstract = [{ - type: 'text', - text: 'Abstract', - }]; - const references = { [image.identifier]: { variants: [{ url: 'image.com', traits: ['1x'] }], From 6dd46d15747d57acb04bfeafe2c20a1f744fa1a6 Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Wed, 7 Sep 2022 11:45:39 +0300 Subject: [PATCH 3/8] refactor: expose a default slot for content in Card.vue --- src/components/Card.vue | 18 ++++++----------- tests/unit/components/Card.spec.js | 32 +++++++++++++++--------------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/src/components/Card.vue b/src/components/Card.vue index a2bf954bc..3c338a0ab 100644 --- a/src/components/Card.vue +++ b/src/components/Card.vue @@ -38,11 +38,9 @@ > {{ title }} - +
+ +
import('docc-render/components/ContentNode.vue'), CardCover, ButtonLink, }, @@ -108,13 +105,10 @@ export default { type: String, required: false, }, - content: { - type: Array, - required: false, - }, url: { type: String, required: false, + default: '', }, eyebrow: { type: String, @@ -126,7 +120,7 @@ export default { }, size: { type: String, - validator: s => s in CardSize, + validator: s => Object.prototype.hasOwnProperty.call(CardSize, s), }, title: { type: String, @@ -264,7 +258,7 @@ $content-margin: 4px; } } -.content { +.card-content { color: var(--color-card-content-text); margin-top: $content-margin; } diff --git a/tests/unit/components/Card.spec.js b/tests/unit/components/Card.spec.js index d36b42835..a64724707 100644 --- a/tests/unit/components/Card.spec.js +++ b/tests/unit/components/Card.spec.js @@ -12,7 +12,6 @@ import { shallowMount } from '@vue/test-utils'; import Card from 'docc-render/components/Card.vue'; import CardSize from 'docc-render/constants/CardSize'; import Reference from 'docc-render/components/ContentNode/Reference.vue'; -import ContentNode from 'docc-render/components/ContentNode.vue'; import { flushPromises } from '../../../test-utils'; const { @@ -44,9 +43,6 @@ describe('Card', () => { const mountCard = options => shallowMount(Card, { propsData, provide, - stubs: { - ContentNode, - }, ...options, }); @@ -188,20 +184,24 @@ describe('Card', () => { expect(h3.text()).toBe(propsData.title); }); - it('renders a `ContentNode` when content is provided', async () => { - const content = [ - { - type: 'paragraph', - inlineContent: [{ type: 'text', text: 'hello world' }], + it('renders content in the default slot', async () => { + const content = 'Foo bar baz'; + const wrapper = mountCard({ + slots: { + default: content, }, - ]; - const propsWithContent = { ...propsData, content }; - const wrapper = mountCard({ propsData: propsWithContent }); + }); + await flushPromises(); + const contentDiv = wrapper.find('.card-content'); + expect(contentDiv.exists()).toBe(true); + expect(contentDiv.text()).toEqual(content); + expect(contentDiv.attributes('id')).toMatch(cardContentIdRE); + }); + + it('does not render the card-content, if no default slot provided', async () => { + const wrapper = mountCard(); await flushPromises(); - const node = wrapper.find(ContentNode); - expect(node.exists()).toBe(true); - expect(node.props('content')).toEqual(content); - expect(node.attributes('id')).toMatch(cardContentIdRE); + expect(wrapper.find('.card-content').exists()).toBe(false); }); it('renders card as a `floatingStyle`', () => { From 4ff3d77415a5cf3b5b167e977c3d93e6ebcc66b1 Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Thu, 8 Sep 2022 10:32:46 +0300 Subject: [PATCH 4/8] refactor: cleanup styling --- src/components/Card.vue | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/components/Card.vue b/src/components/Card.vue index 3c338a0ab..1883a7d64 100644 --- a/src/components/Card.vue +++ b/src/components/Card.vue @@ -216,10 +216,7 @@ $content-margin: 4px; padding: $details-padding; position: relative; height: var(--card-details-height); - - .small &, & { - @include font-styles(card-content-small); - } + @include font-styles(card-content-small); .large & { @include font-styles(card-content-large); @@ -235,10 +232,7 @@ $content-margin: 4px; color: var(--color-card-eyebrow); display: block; margin-bottom: $content-margin; - - .small &, & { - @include font-styles(card-eyebrow-small); - } + @include font-styles(card-eyebrow-small); .large & { @include font-styles(card-eyebrow-large); @@ -248,10 +242,7 @@ $content-margin: 4px; .title { font-weight: $font-weight-semibold; color: var(--color-card-content-text); - - .small &, & { - @include font-styles(card-title-small); - } + @include font-styles(card-title-small); .large & { @include font-styles(card-title-large); From c121978d3dd218d5e06bfd42143ad7d4d3669df5 Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Thu, 8 Sep 2022 10:33:02 +0300 Subject: [PATCH 5/8] refactor: extract reusable utility --- src/components/CardCover.vue | 4 +--- src/components/Tutorial/Hero.vue | 6 +++--- src/utils/assets.js | 7 +++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/CardCover.vue b/src/components/CardCover.vue index bc4e8184d..6132cd769 100644 --- a/src/components/CardCover.vue +++ b/src/components/CardCover.vue @@ -19,9 +19,7 @@ diff --git a/tests/unit/components/CardCover.spec.js b/tests/unit/components/CardCover.spec.js index 462019141..214f0f77d 100644 --- a/tests/unit/components/CardCover.spec.js +++ b/tests/unit/components/CardCover.spec.js @@ -10,6 +10,7 @@ import { shallowMount } from '@vue/test-utils'; import CardCover from 'docc-render/components/CardCover.vue'; +import ImageAsset from 'docc-render/components/ImageAsset.vue'; const lightVariant = { url: 'https://image.light', @@ -39,23 +40,11 @@ const mountCover = ({ propsData, ...rest } = {}) => { }; describe('CardCover', () => { - it('renders images for light/dark backgrounds', () => { + it('renders an ``', () => { const wrapper = mountCover(); - const covers = wrapper.findAll('.card-cover'); - expect(covers).toHaveLength(2); - expect(wrapper.find('.card-cover--light').attributes('style')) - .toContain(`background-image: url(${lightVariant.url});`); - expect(wrapper.find('.card-cover--dark').attributes('style')) - .toContain(`background-image: url(${darkVariant.url});`); - }); - - it('falls back to light image if dark variant is not available', () => { - const wrapper = mountCover({ - propsData: { - variants: [lightVariant], - }, - }); - expect(wrapper.find('.card-cover--dark').attributes('style')).toContain(lightVariant.url); + const asset = wrapper.find(ImageAsset); + expect(asset.classes()).toContain('card-cover'); + expect(asset.props('variants')).toEqual(defaultProps.variants); }); it('exposes a default slot', () => { From 89ba849b688740305530115d0bd6db02385fd53f Mon Sep 17 00:00:00 2001 From: Dobromir Hristov Date: Tue, 20 Sep 2022 12:15:05 +0300 Subject: [PATCH 8/8] fix: issues with none existent AX tags --- src/components/Card.vue | 8 +++++--- tests/unit/components/Card.spec.js | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/Card.vue b/src/components/Card.vue index 1883a7d64..587f8c8f6 100644 --- a/src/components/Card.vue +++ b/src/components/Card.vue @@ -81,9 +81,11 @@ export default { titleId: ({ _uid }) => `card_title_${_uid}`, contentId: ({ _uid }) => `card_content_${_uid}`, eyebrowId: ({ _uid }) => `card_eyebrow_${_uid}`, - linkAriaTags: ({ titleId, eyebrowId, contentId }) => ({ - 'aria-labelledby': `${titleId} ${eyebrowId}`, - 'aria-describedby': `${contentId}`, + linkAriaTags: ({ + titleId, eyebrowId, contentId, eyebrow, $slots, + }) => ({ + 'aria-labelledby': titleId.concat(eyebrow ? ` ${eyebrowId}` : ''), + 'aria-describedby': $slots.default ? `${contentId}` : null, }), classes: ({ size, diff --git a/tests/unit/components/Card.spec.js b/tests/unit/components/Card.spec.js index a64724707..9af1f7b65 100644 --- a/tests/unit/components/Card.spec.js +++ b/tests/unit/components/Card.spec.js @@ -43,6 +43,9 @@ describe('Card', () => { const mountCard = options => shallowMount(Card, { propsData, provide, + slots: { + default: '
Default content
', + }, ...options, }); @@ -73,6 +76,20 @@ describe('Card', () => { expect(card.find('.details').attributes('aria-hidden')).toBe('true'); }); + it('does not add invisible component IDs in the AX tags', () => { + const card = mountCard({ + propsData: { + ...propsData, + eyebrow: '', + }, + slots: { + default: '', + }, + }); + expect(card.attributes('aria-labelledby')).not.toContain('card_eyebrow_'); + expect(card.attributes('aria-describedby')).toBeFalsy(); + }); + it('renders `.link` with an `DiagonalArrowIcon` if external', () => { const wrapper = mountCard({ propsData: { @@ -199,7 +216,11 @@ describe('Card', () => { }); it('does not render the card-content, if no default slot provided', async () => { - const wrapper = mountCard(); + const wrapper = mountCard({ + slots: { + default: '', + }, + }); await flushPromises(); expect(wrapper.find('.card-content').exists()).toBe(false); });