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 @@ + 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