diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ad6f654b..b632bd80b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ Changelog is rather internal in nature. See release notes for the public overvie
## Upcoming version 5.x.x (`develop` branch)
+[#625]
+ - **Description:** Initial implementation of `KCard` component
+ - **Products impact:** New Component
+ - **Addresses:** [#530](https://github.com/learningequality/kolibri-design-system/issues/530)
+ - **Components:** KCard
+ - **Breaking:** No
+ - **Impacts a11y:** Yes
+ - **Guidance:**
+[#625]: https://github.com/learningequality/kolibri-design-system/pull/625
+
- [#678]
- **Description:** Add `previewUnavailable` icon
- **Products impact:** new icon
diff --git a/docs/pages/kcard.vue b/docs/pages/kcard.vue
new file mode 100644
index 000000000..3a10b622d
--- /dev/null
+++ b/docs/pages/kcard.vue
@@ -0,0 +1,391 @@
+
+
+
+
+
+ The KCard
component is a versatile and accessible base component
+ for creating various card types, including lesson cards, resource cards, and channel cards.
+ It provides slots for adding content such as titles, subtitles, and footers,
+ along with options for displaying thumbnail images and customizing the layout.
+
+
+
+
+
+ KCard
offers two main types of layouts to accommodate different content needs: horizontal and vertical.
+
+ Horizontal Layout
+ The horizontal layout displays the thumbnail image on the left and the content on the right.
+ Here's an example of how to use the horizontal layout:
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rutrum pellentesque utrum...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Vertical Layout
+ The vertical layout displays the thumbnail image above the content.
+ Here's an example of how to use the vertical layout:
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rutrum pellentesque utrum...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ KCard provides options for displaying thumbnails alongside content.
+ The
+ thumbnailDisplay
+ prop controls whether a thumbnail is shown or hidden.
+
+
+ The thumbnail's display options depend on two factors: the size of the original image and the chosen
+
+ thumbnailScaleType
+ prop. This property controls how the image is scaled to fit the designated thumbnail area.
+ The available scaling options are the same as those offered by the KImg component's scaleTypes property.
+
+
+ If the
+ thumbnailDisplay
+ prop is set to "none", the thumbnail will not be displayed.
+
+
+
+
+
+
+
+
+
+
+
+ Class name 1
+
+
+
+
+
+
+ score 30%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Class name 1
+
+
+
+
+
+
+ score 30%
+
+
+
+
+
+
+
+
+
+
+
+
+ And if the
+ thumbnailDisplay
+ prop is set to "small", the thumbnail will be displayed at a small size.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rutrum pellentesque utrum...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The thumbnail's display options depend on two factors: the size of the original image and the chosen
+ thumbnailScaleType prop. This property controls how the image is scaled to fit the designated thumbnail area.
+ The available scaling options are the same as those offered by the KImg component's scaleTypes property.
+
+
+ Thumbnail Display
+
+
+
+ - none: No thumbnail is displayed.
+ - small: Sets thumbnailDisplay to a small size.
+ - large: Sets thumbnailDisplay to a large size.
+
+
+ Thumbnail Src
+
+ The thumbnailSrc prop is used to set the source URL of the thumbnail image.
+
+ humbnail Scale Type
+
+ The thumbnailScaleType prop is used to set the scaling type of the thumbnail image. ptions: 'centerInside', 'contain', 'fitXY'.
+ The available scaling options are the same as those offered by the KImg component's scaleTypes property.
+
+
+
+
+
diff --git a/docs/tableOfContents.js b/docs/tableOfContents.js
index c6aa32e34..bad622b79 100644
--- a/docs/tableOfContents.js
+++ b/docs/tableOfContents.js
@@ -396,6 +396,12 @@ export default [
title: 'KListWithOverflow',
isCode: true,
}),
+ new Page({
+ path: '/kcard',
+ title: 'KCard',
+ isCode: true,
+ keywords: layoutRelatedKeywords,
+ }),
],
}),
];
diff --git a/lib/KCard/BaseCard.vue b/lib/KCard/BaseCard.vue
new file mode 100644
index 000000000..3864e73b3
--- /dev/null
+++ b/lib/KCard/BaseCard.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/KCard/__tests__/KCard.spec.js b/lib/KCard/__tests__/KCard.spec.js
new file mode 100644
index 000000000..7c619b643
--- /dev/null
+++ b/lib/KCard/__tests__/KCard.spec.js
@@ -0,0 +1,131 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import KCard from './../index.vue';
+
+const localVue = createLocalVue();
+localVue.use(VueRouter);
+
+const router = new VueRouter({
+ routes: [{ path: '/some-link' }],
+});
+
+function makeWrapper({ propsData = {}, slots = {} } = {}) {
+ return mount(KCard, { router, propsData, slots, localVue });
+}
+
+/* checks the expected card markup:
+
+
+ Test Title
+
+
+ all other content
+ */
+function checkExpectedCardMarkup({ wrapper, headingLevel, title }) {
+ const firstElement = wrapper.find(':first-child');
+ expect(firstElement.exists()).toBe(true);
+ expect(firstElement.element.tagName.toLowerCase()).toBe('li');
+
+ const secondElement = wrapper.find('li > :first-child');
+ expect(secondElement.exists()).toBe(true);
+ expect(secondElement.element.tagName.toLowerCase()).toBe(`h${headingLevel}`);
+
+ const thirdElement = wrapper.find(`h${headingLevel} > :first-child`);
+ expect(thirdElement.element.tagName.toLowerCase()).toBe('a');
+ expect(thirdElement.exists()).toBe(true);
+
+ const linkText = thirdElement.text();
+ expect(linkText).toBe(title);
+}
+
+describe('KCard', () => {
+ it('renders passed header level', () => {
+ const wrapper = makeWrapper({
+ propsData: { headingLevel: 4, title: 'sample title prop', to: { path: '/some-link' } },
+ });
+
+ const heading = wrapper.find('h4');
+ expect(heading.exists()).toBe(true);
+ });
+
+ it('renders the correct accessibility structure when title passed via slot', () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, headingLevel: 4 },
+ slots: {
+ title: 'Test Title',
+ },
+ });
+ checkExpectedCardMarkup({ wrapper, headingLevel: 4, title: 'Test Title' });
+ });
+
+ it('renders the correct accessibility structure when title passed via prop', () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, headingLevel: 4, title: 'Test Title' },
+ });
+ checkExpectedCardMarkup({ wrapper, headingLevel: 4, title: 'Test Title' });
+ });
+
+ it('should not navigate on long click to allow for text selection', async () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, title: 'sample title prop' },
+ });
+
+ await wrapper.find('li').trigger('mousedown');
+ await new Promise(resolve => setTimeout(resolve, 500));
+ await wrapper.find('li').trigger('mouseup');
+ expect(wrapper.vm.$router.currentRoute.path).not.toBe('/some-link');
+ });
+
+ it('should navigate on quick click', async () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, title: 'sample title ' },
+ });
+
+ await wrapper.find('li').trigger('mousedown');
+ await new Promise(resolve => setTimeout(resolve, 100));
+ await wrapper.find('li').trigger('mouseup');
+
+ expect(wrapper.vm.$router.currentRoute.path).toBe('/some-link');
+ });
+
+ describe('it renders slotted content', () => {
+ it('renders slotted content with aboveTitle slot', () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, title: 'sample title ' },
+ slots: {
+ aboveTitle: 'above title',
+ },
+ });
+
+ const aboveTitleSlot = wrapper.find('[data-test="aboveTitle"]');
+ expect(aboveTitleSlot.exists()).toBe(true);
+ expect(aboveTitleSlot.text()).toBe('above title');
+ });
+
+ it('renders slotted content with belowTitle slot', () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, title: 'sample title ' },
+ slots: {
+ belowTitle: 'below title',
+ },
+ });
+
+ const belowTitleSlot = wrapper.find('[data-test="belowTitle"]');
+ expect(belowTitleSlot.exists()).toBe(true);
+ expect(belowTitleSlot.text()).toBe('below title');
+ });
+
+ it('renders slotted content with footer slot', () => {
+ const wrapper = makeWrapper({
+ propsData: { to: { path: '/some-link' }, title: 'sample title ' },
+ slots: {
+ footer: 'footer slot content goes here',
+ },
+ });
+
+ const footerSlot = wrapper.find('[data-test="footer"]');
+ expect(footerSlot.exists()).toBe(true);
+ expect(footerSlot.text()).toBe('footer slot content goes here');
+ });
+ });
+});
diff --git a/lib/KCard/index.vue b/lib/KCard/index.vue
new file mode 100644
index 000000000..fa55b8a18
--- /dev/null
+++ b/lib/KCard/index.vue
@@ -0,0 +1,396 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/KThemePlugin.js b/lib/KThemePlugin.js
index 9e189f875..8757ecdc1 100644
--- a/lib/KThemePlugin.js
+++ b/lib/KThemePlugin.js
@@ -34,6 +34,7 @@ import KTooltip from './KTooltip';
import KTransition from './KTransition';
import KTextTruncator from './KTextTruncator';
import KLogo from './KLogo';
+import KCard from './KCard';
import { themeTokens, themeBrand, themePalette, themeOutlineStyle } from './styles/theme';
import globalThemeState from './styles/globalThemeState';
@@ -127,4 +128,5 @@ export default function KThemePlugin(Vue) {
Vue.component('KTooltip', KTooltip);
Vue.component('KTransition', KTransition);
Vue.component('KTextTruncator', KTextTruncator);
+ Vue.component('KCard', KCard);
}
diff --git a/lib/styles/trackInputModality.js b/lib/styles/trackInputModality.js
index 45df34542..7bb0fad00 100644
--- a/lib/styles/trackInputModality.js
+++ b/lib/styles/trackInputModality.js
@@ -25,6 +25,8 @@ function setUpEventHandlers(disableFocusRingByDefault) {
'[role=textbox]',
'a',
'button',
+ // Custom KDS attribute. When attached to an element, it will receive a focus ring. Typically used for a11y optimizations, such as for KCard's .
+ '[data-focus=true]',
].join(',');
// add this to any element to allow keyboard navigation, regardless of focus event