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 @@
+
+
+
+
+
+
+
+
+
+ {{ eyebrow }}
+
+
+ {{ title }}
+
+
+
+
+
+ {{ linkText }}
+
+
+
+
+
+
+
+
+
+
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');
+ });
+});