diff --git a/src/components/Card.vue b/src/components/Card.vue new file mode 100644 index 000000000..587f8c8f6 --- /dev/null +++ b/src/components/Card.vue @@ -0,0 +1,310 @@ + + + + + + + diff --git a/src/components/CardCover.vue b/src/components/CardCover.vue new file mode 100644 index 000000000..cb38881fe --- /dev/null +++ b/src/components/CardCover.vue @@ -0,0 +1,61 @@ + + + + + + diff --git a/src/components/Tutorial/Hero.vue b/src/components/Tutorial/Hero.vue index 6104b522e..c1fcbd27d 100644 --- a/src/components/Tutorial/Hero.vue +++ b/src/components/Tutorial/Hero.vue @@ -73,7 +73,7 @@ import LinkableElement from 'docc-render/components/LinkableElement.vue'; import GenericModal from 'docc-render/components/GenericModal.vue'; import PlayIcon from 'theme/components/Icons/PlayIcon.vue'; -import { normalizeAssetUrl } from 'docc-render/utils/assets'; +import { normalizeAssetUrl, toCSSUrl } from 'docc-render/utils/assets'; import HeroMetadata from './HeroMetadata.vue'; export default { @@ -140,14 +140,14 @@ export default { variant.traits.includes('light') )); - return lightVariant ? normalizeAssetUrl(lightVariant.url) : ''; + return (lightVariant || {}).url; }, projectFilesUrl() { return this.projectFiles ? normalizeAssetUrl(this.references[this.projectFiles].url) : null; }, bgStyle() { return { - backgroundImage: `url('${this.backgroundImageUrl}')`, + backgroundImage: toCSSUrl(this.backgroundImageUrl), }; }, xcodeRequirementData() { diff --git a/src/constants/CardSize.js b/src/constants/CardSize.js new file mode 100644 index 000000000..651f6febc --- /dev/null +++ b/src/constants/CardSize.js @@ -0,0 +1,14 @@ +/** + * 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/src/styles/core/colors/_dark.scss b/src/styles/core/colors/_dark.scss index 04aa06825..179e37f7d 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: #{change-color(light-color(fill), $alpha: 0.04)}; } diff --git a/src/styles/core/colors/_light.scss b/src/styles/core/colors/_light.scss index a32666ebc..ce664ad6f 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: #{change-color(dark-color(fill), $alpha: 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/src/utils/assets.js b/src/utils/assets.js index d31d4c0c4..ccf8ba555 100644 --- a/src/utils/assets.js +++ b/src/utils/assets.js @@ -75,3 +75,10 @@ export function normalizeAssetUrl(url) { } return pathJoin([baseUrl, url]); } + +/** + * Transforms a URL string into a normalized css `url(/path)` format. + * @param {String} url + * @returns {string|undefined} + */ +export function toCSSUrl(url) { return url ? `url('${normalizeAssetUrl(url)}')` : undefined; } diff --git a/tests/unit/components/Card.spec.js b/tests/unit/components/Card.spec.js new file mode 100644 index 000000000..9af1f7b65 --- /dev/null +++ b/tests/unit/components/Card.spec.js @@ -0,0 +1,238 @@ +/** + * 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 { flushPromises } from '../../../test-utils'; + +const { + CardCover, ButtonLink, DiagonalArrowIcon, InlineChevronRightIcon, +} = Card.components; + +describe('Card', () => { + const image = { + identifier: 'identifier', + }; + + 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, + slots: { + default: '
Default content
', + }, + ...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('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: { + ...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 content in the default slot', async () => { + const content = 'Foo bar baz'; + const wrapper = mountCard({ + slots: { + default: content, + }, + }); + 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({ + slots: { + default: '', + }, + }); + await flushPromises(); + expect(wrapper.find('.card-content').exists()).toBe(false); + }); + + 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..214f0f77d --- /dev/null +++ b/tests/unit/components/CardCover.spec.js @@ -0,0 +1,66 @@ +/** + * 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'; +import ImageAsset from 'docc-render/components/ImageAsset.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 an ``', () => { + const wrapper = mountCover(); + const asset = wrapper.find(ImageAsset); + expect(asset.classes()).toContain('card-cover'); + expect(asset.props('variants')).toEqual(defaultProps.variants); + }); + + 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'); + }); +});