diff --git a/docs/assets/main.scss b/docs/assets/main.scss index 60f0408ae..a4c9f877b 100644 --- a/docs/assets/main.scss +++ b/docs/assets/main.scss @@ -16,7 +16,6 @@ code { padding-left: 0.25em; font-family: 'Source Code Pro', monospace; font-weight: 400; - white-space: nowrap; background-color: #918caf24; border-radius: 0.25em; } diff --git a/docs/pages/kcard.vue b/docs/pages/kcard.vue index 889979dee..0e1dcef4e 100644 --- a/docs/pages/kcard.vue +++ b/docs/pages/kcard.vue @@ -7,7 +7,11 @@

It manages the layout, including the thumbnail image, title, and other content. It offers several base layouts and many customization options. Cards like the examples shown can be created, and many others.

- + KCard has two orientations: horizontal and vertical. It is also possible to configure whether a thumbnail area is displayed, its size and alignment. By combining orientation, thumbnailDisplay and thumbnailAlign props, the following card layouts can be achieved to organize diverse kinds of content:

- + - + - + Use aboveTitle, belowTitle, and footer slots to add content to a card. KCard will organize these areas according to its . Apply custom styling to the inner content of slots to achieve desired effects.

- + When KCard is set to display the thumbnail, the thumbnail area acts as a placeholder if the image is missing, fails to load, or is still loading. In such cases, a light gray background is shown in place of the image.

-

Use the thumbnailPlaceholder slot to add a placeholder element, such as an icon, to this area. Provide a placeholder element even if a thumbnail image is available. This serves as a progressive loading experience where the placeholder element is displayed until the image is loaded, and is particularly important on slower networks.

+

Use the thumbnailPlaceholder slot to add a placeholder element, such as an icon, to this area. Provide a placeholder element even if a thumbnail image is available. It serves as fallback content if the image fails to load unexpectedly.

- + This applies to all slot content, but considering accessibility is especially important with interactive elements. For instance, ariaLabel is applied to the bookmark icon button in the following example so that screenreaders can communicate its purpose. In production, more work would be needed to indicate the bookmark's toggled state. Always assess on a case-by-case basis.

- + Managing the selection state is not KCard's responsibility.

- + { + this.loading = false; + }, 3000); + }, }; diff --git a/docs/pages/kcardgrid.vue b/docs/pages/kcardgrid.vue index 4b5fe735c..7b33dbb99 100644 --- a/docs/pages/kcardgrid.vue +++ b/docs/pages/kcardgrid.vue @@ -4,7 +4,7 @@

Displays a grid of cards .

-

KCardGrid provides base layouts for the most common grids in our ecosystem, as well as advanced configuration via useKCardGrid (TBD), allowing customization or complete override of the base layouts.

+

KCardGrid provides base layouts for the most common grids in our ecosystem, as well as customization or complete override of the base layouts.

Together with KCard, it ensures accessible navigation within card lists, such as announcing only their titles when using the tab key to avoid overwhelming screen reader outputs.

@@ -28,6 +28,7 @@ Ensure robust content tolerance and consistent content alignment ()
  • Preview cards on all screen sizes ()
  • +
  • Configure loading skeleton cards to match the expected visual output of cards with loaded data as closely as possible on all screen sizes ()
  • Also follow .

    @@ -37,10 +38,10 @@ @@ -85,7 +86,11 @@ - + - + - + -

    Base layouts can be customized or even completely overriden. Refer to useKCardGrid (TBD).

    +

    Base layouts can be customized or even completely overriden via the layoutOverride prop. layoutOverride takes an array of objects { breakpoints, cardsPerRow, columnGap, rowGap }, where:

    + +
      +
    • breakpoints is an array of 0-7 values corresponding to the . All other attributes in the same object take effect on these breakpoints.
    • +
    • cardsPerRow overrides the number of cards per row for the specified breakpoints.
    • +
    • columnGap/rowGap overrides grid column/row gaps for the specified breakpoints.
    • +
    + +

    For example:

    + + + + + + + + + + + + + + + + + + export default { + ... + data() { + return { + layoutOverride: [ + { + breakpoints: [0, 1], + columnGap: '20px', + rowGap: '20px', + }, + { + breakpoints: [4, 5, 6, 7], + cardsPerRow: 4, + }, + ], + }; + }, + }; + + + +

    Here, the base 1-2-3 layout is overriden partially. Column and row gaps are decreased to 20px on breakpoints 0-1, and the number of cards per row is increased to 4 on breakpoints 4-7.

    Card height, content tolerance and alignment @@ -233,8 +310,12 @@

    Setting height on cards is discouraged. Instead, manage height bottom-up, for example by setting height on card sections, using text truncation, or other ways to limit its inner content. Such approaches ensure content tolerance, prevent from unexpected overflows or excessive height, and keep vertical alignment of card sections consistent on a grid row. This is especially important when dealing with unknown lenghts or amounts of content displayed in cards. Consider:

    - - + + Grid configuration can be combined with KCard's settings to further improve responsive experience. A common pattern is switching KCard's horizontal orientation to vertical for smaller screens to organize content more effectively in limited space:

    - + This technique also works for adjusting KCard slots content. In the following example, some metadata pills are hidden on smaller screens:

    - +

    -

    When cards are loading, KCardGrid displays skeleton cards...(TBD)

    +

    While data is loading, KCardGrid shows loading skeleton cards. Use the loading prop to toggle the loading state. Note that KCardGrid internal optimizations may affect how closely the visual loading experience matches the loading value:

    + +
      +
    • The loading skeletons won't be displayed for short loading times (< 1s)
    • +
    • When the loading skeletons are displayed, they will be visible for at least 1s
    • +
    + +

    Use the buttons in the example below to preview.

    + +

    Number of loading skeletons

    + +

    By default, the number of loading skeletons corresponds to the number of cards in a single grid row if it were full. This behavior can be overridden via the count attribute (below), however do not override it unless indicated in the designs.

    + +

    Loading skeletons configuration

    + +

    Use the skeletonsConfig prop to configure skeleton cards to match the expected visual output of loaded cards on all screen sizes. Preview the layout and height of cards with loaded data and adjust skeletonsConfig accordingly.

    + +

    skeletonsConfig takes an array of objects { breakpoints, count, height, orientation, thumbnailDisplay, thumbnailAlign }, where:

    + +
      +
    • breakpoints is an array of 0-7 values corresponding to the . All other attributes in the same object take effect on these breakpoints.
    • +
    • count sets the number of skeleton cards for the specified breakpoints. See .
    • +
    • height sets the height of skeleton cards for the specified breakpoints.
    • +
    • orientation sets the orientation of skeleton cards for the specified breakpoints. Corresponds to .
    • +
    • thumbnailDisplay sets the thumbnail display of skeleton cards for the specified breakpoints. Corresponds to .
    • +
    • thumbnailAlign sets the thumbnail alignment of skeleton cards for the specified breakpoints. Corresponds to .
    • +
    + +

    For easier development, enable the debug prop to display the current breakpoint in the top left corner of the grid. Use the button in the example below to preview the debug mode.

    + +
    + + + Load (0.5 s) + + + Load (1.2 s) + + + Load (4 s) + + + Debug: {{ debug ? 'On' : 'Off' }} + + +
    + + + + + + + + + + + + + + + + + + export default { + ... + data() { + return { + skeletonsConfig: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + height: '400px', + orientation: 'vertical', + thumbnailDisplay: 'large', + thumbnailAlign: 'left' + }, + { + breakpoints: [4, 5, 6, 7], + height: '220px', + orientation: 'horizontal' + } + ], + }; + }, + }; + + + +

    Here, the height of loading skeleton cards is 400px with vertical orientation on breakpoints 0-3, and 220px with horizontal orientation on breakpoints 4-7. This makes skeleton cards resemble loaded cards at all breakpoints, creating a smooth transition for users during data loading. Note the bottom-up approach where we begin with a base setup for all breakpoints and gradually override on higher breakpoints. This simplifies the configuration object.

    + +

    To get a sense of what skeleton layouts can be achieved, reload this page and the KCard page to preview the loading state in all examples.

    @@ -536,13 +735,157 @@ return { windowBreakpoint }; }, data() { - return {}; + return { + debug: false, + loading: true, + skeletonsConfig1: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'horizontal', + thumbnailDisplay: 'large', + thumbnailAlign: 'left', + height: '250px', + }, + { + breakpoints: [3, 4, 5, 6, 7], + height: '180px', + }, + ], + skeletonsConfig2: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '470px', + }, + { + breakpoints: [2, 3], + height: '430px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '360px', + }, + ], + skeletonsConfig3: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '470px', + }, + { + breakpoints: [2, 3], + height: '430px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '390px', + }, + ], + skeletonsConfig4: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '360px', + }, + ], + skeletonsConfig5: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '420px', + }, + { + breakpoints: [3, 4, 5, 6, 7], + height: '390px', + }, + ], + skeletonsConfig6: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '440px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '220px', + orientation: 'horizontal', + thumbnailAlign: 'left', + }, + ], + skeletonsConfig7: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '430px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '370px', + }, + ], + skeletonsConfig8: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '400px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '220px', + orientation: 'horizontal', + thumbnailAlign: 'left', + }, + ], + layoutOverride: [ + { + breakpoints: [0, 1], + columnGap: '20px', + rowGap: '20px', + }, + { + breakpoints: [4, 5, 6, 7], + cardsPerRow: 4, + }, + ], + }; }, computed: { slicedPills() { return ['Short Activity', 'Biology', 'Ecology', 'Ornithology'].slice(0, 2); }, }, + mounted() { + setTimeout(() => { + this.loading = false; + }, 3000); + }, + methods: { + load500() { + this.loading = true; + setTimeout(() => { + this.loading = false; + }, 500); + }, + load1200() { + this.loading = true; + setTimeout(() => { + this.loading = false; + }, 1200); + }, + load4000() { + this.loading = true; + setTimeout(() => { + this.loading = false; + }, 4000); + }, + }, }; diff --git a/lib/cards/KCard.vue b/lib/cards/KCard.vue index 286ac896d..29eaeb4db 100644 --- a/lib/cards/KCard.vue +++ b/lib/cards/KCard.vue @@ -71,17 +71,10 @@ :aspectRatio="thumbnailAspectRatio" :isDecorative="true" :appearanceOverrides="thumbnailStyles" - @load="isThumbnailImageLoaded = true" - @error="isThumbnailImageLoaded = false" + @error="onThumbnailError" /> - @@ -285,42 +278,29 @@ type: Boolean, default: false, }, + /** + * Private. Do not use. + */ + // Disables validations and functionality + // that shouldn't be present when card + // used as loading skeleton via `SkeletonCard` + isSkeleton: { + type: Boolean, + default: false, + }, }, data() { return { mouseDownTime: 0, ThumbnailDisplays, - isThumbnailImageLoaded: false, isLinkFocused: false, + thumbnailError: false, }; }, computed: { focusStyle() { return this.isLinkFocused ? this.$coreOutline : {}; }, - /** - * Disable the thumbnail placeholder element when - * there is no thumbnail area or the placeholder element - * is not provided. - * - * Furthermore, hide it after the thumbnail image - * is successfully loaded. Otherwise in some scenarios, - * such as when there is a large placeholder element - * and a small thumbnail image, some parts of the placeholder - * element may be visible behind the image after it has been - * successfully loaded. - * - * However, do not hide the placeholder element while - * the image is still loading to ensure progressive - * loading experience on slower networks. - */ - disableThumbnailPlaceholder() { - return ( - this.thumbnailDisplay === this.ThumbnailDisplays.NONE || - !this.$slots.thumbnailPlaceholder || - this.isThumbnailImageLoaded - ); - }, hasAboveTitleArea() { return this.$slots.aboveTitle || this.preserveAboveTitle; }, @@ -457,12 +437,21 @@ }, methods: { onLinkFocus() { + if (this.isSkeleton) { + return; + } this.isLinkFocused = true; }, onLinkBlur() { this.isLinkFocused = false; }, + onThumbnailError() { + this.thumbnailError = true; + }, navigate() { + if (this.isSkeleton) { + return; + } this.$router.push(this.to); }, onFocus(e) { @@ -484,6 +473,9 @@ this.mouseDownTime = new Date().getTime(); }, onClick() { + if (this.isSkeleton) { + return; + } const mouseUpTime = new Date().getTime(); // Make textual content selectable within the whole clickable card area. // @@ -556,11 +548,6 @@ font-size: 16px; font-weight: 600; line-height: 1.5; - - a { - color: inherit; - text-decoration: none; - } } .thumbnail { @@ -594,6 +581,9 @@ } .link { + display: inline-block; // allows title placeholder in the skeleton card + width: 100%; // allows title placeholder in the skeleton card + color: inherit; text-decoration: none; outline: none; // the focus ring is moved to the whole
  • } diff --git a/lib/cards/KCardGrid.vue b/lib/cards/KCardGrid.vue index c8abdc7ae..d7ef459bc 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -1,12 +1,42 @@ @@ -16,28 +46,40 @@ import { watch, ref, provide } from '@vue/composition-api'; import { LAYOUT_1_1_1, LAYOUT_1_2_2, LAYOUT_1_2_3 } from './gridBaseLayouts'; - import useResponsiveGridLayout from './useResponsiveGridLayout'; + import useGridLayout from './useGridLayout'; + import useGridLoading from './useGridLoading'; + import SkeletonCard from './SkeletonCard'; /** * Displays a grid of cards `KCard`. - * - * Offers default behavior corresponding to the most - * commonly used grids, as well as advance configuration - * via `useKCardGrid` to customize a base grid layout - * or even completely override it. */ export default { name: 'KCardGrid', - + components: { + SkeletonCard, + }, setup(props) { - const { currentLevelConfig } = useResponsiveGridLayout(props); + const { currentBreakpointConfig, windowBreakpoint } = useGridLayout(props); + const { + showGrid, + isLoading, + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, + } = useGridLoading(props); const gridStyle = ref({}); const gridItemStyle = ref({}); watch( - currentLevelConfig, + currentBreakpointConfig, newValue => { + if (!newValue) { + return; + } + const { cardsPerRow, columnGap, rowGap } = newValue; gridStyle.value = { @@ -60,7 +102,15 @@ provide('gridItemStyle', gridItemStyle); return { + windowBreakpoint, gridStyle, + isLoading, + showGrid, + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, }; }, props: { @@ -77,6 +127,47 @@ return [LAYOUT_1_1_1, LAYOUT_1_2_2, LAYOUT_1_2_3].includes(value); }, }, + // eslint-enable-next-line kolibri/vue-no-unused-properties + /** + * Overrides the base grid `layout` for chosen breakpoints levels + */ + // eslint-disable-next-line kolibri/vue-no-unused-properties + layoutOverride: { + type: Array, + required: false, + default: null, + }, + // eslint-enable-next-line kolibri/vue-no-unused-properties + /** + * Set to `true` as long as data for cards + * are being loaded to display loading skeletons + */ + // eslint-disable-next-line kolibri/vue-no-unused-properties + loading: { + type: Boolean, + default: false, + }, + // eslint-enable-next-line kolibri/vue-no-unused-properties + /** + * Configures loading skeletons + */ + // eslint-disable-next-line kolibri/vue-no-unused-properties + skeletonsConfig: { + type: Array, + required: false, + default: null, + }, + // eslint-enable kolibri/vue-no-unused-properties + /** + * Use for development only. Shows information in + * the grid's corner that is useful for configuring + * loading skeletons. + * + */ + debug: { + type: Boolean, + default: false, + }, }, }; @@ -85,7 +176,29 @@ \ No newline at end of file diff --git a/lib/cards/SkeletonCard.vue b/lib/cards/SkeletonCard.vue new file mode 100644 index 000000000..516fbe401 --- /dev/null +++ b/lib/cards/SkeletonCard.vue @@ -0,0 +1,134 @@ + + + + + + + \ No newline at end of file diff --git a/lib/cards/__tests__/KCard.spec.js b/lib/cards/__tests__/KCard.spec.js index 41acbf597..491aaa898 100644 --- a/lib/cards/__tests__/KCard.spec.js +++ b/lib/cards/__tests__/KCard.spec.js @@ -246,7 +246,7 @@ describe('KCard', () => { expect(wrapper.find('[data-test="placeholderIcon"]').exists()).toBe(false); }); - it('is displayed when a thumbnail image is not provided', () => { + it('is displayed when a thumbnail image source is not provided', () => { const wrapper = makeWrapper({ propsData: { to: { path: '/some-link' }, @@ -263,23 +263,6 @@ describe('KCard', () => { expect(wrapper.find('[data-test="placeholderIcon"]').exists()).toBe(true); }); - it('is displayed while a thumbnail image is loading (progressive loading experience on slow network)', () => { - const wrapper = makeWrapper({ - propsData: { - to: { path: '/some-link' }, - title: 'sample title ', - headingLevel: 4, - thumbnailSrc: '/thumbnail-img.jpg', - thumbnailDisplay: 'large', - }, - slots: { - thumbnailPlaceholder: '', - }, - }); - - expect(wrapper.find('[data-test="placeholderIcon"]').exists()).toBe(true); - }); - it('is displayed when a thumbnail image could not be loaded', async () => { const wrapper = makeWrapper({ propsData: { @@ -301,7 +284,7 @@ describe('KCard', () => { expect(wrapper.find('[data-test="placeholderIcon"]').exists()).toBe(true); }); - it('is not displayed after a thumbnail image is successfully loaded', async () => { + it('is not displayed when a thumbnail image is available', () => { const wrapper = makeWrapper({ propsData: { to: { path: '/some-link' }, @@ -314,10 +297,6 @@ describe('KCard', () => { thumbnailPlaceholder: '', }, }); - wrapper.find('[data-test="thumbnail-img"]').vm.$emit('load'); - - // wait for re-render - await wrapper.vm.$nextTick(); expect(wrapper.find('[data-test="placeholderIcon"]').exists()).toBe(false); }); diff --git a/lib/cards/__tests__/utils.spec.js b/lib/cards/__tests__/utils.spec.js new file mode 100644 index 000000000..95f925fcd --- /dev/null +++ b/lib/cards/__tests__/utils.spec.js @@ -0,0 +1,66 @@ +import { getBreakpointConfig } from '../utils'; + +const config = [ + { + breakpoints: [2, 3], + count: 1, + orientation: 'vertical', + thumbnailDisplay: 'small', + thumbnailAlign: 'right', + }, + { + breakpoints: [4, 5, 6, 7], + count: 2, + orientation: 'horizontal', + thumbnailDisplay: 'large', + thumbnailAlign: 'left', + }, + { + breakpoints: [4, 5], + height: '250px', + }, + { + breakpoints: [5], + height: '200px', + }, +]; + +describe('getBreakpointConfig', () => { + it('returns the correct breakpoint config', () => { + expect(getBreakpointConfig(config, 2)).toEqual({ + count: 1, + orientation: 'vertical', + thumbnailDisplay: 'small', + thumbnailAlign: 'right', + }); + }); + + it('populates all configuration for a breakpoint that overlaps multiple sub-configs', () => { + expect(getBreakpointConfig(config, 4)).toEqual({ + count: 2, + orientation: 'horizontal', + thumbnailDisplay: 'large', + thumbnailAlign: 'left', + height: '250px', + }); + }); + + it('gives preference to a last value for a breakpoint that overlaps multiple sub-configs with competing values', () => { + expect(getBreakpointConfig(config, 5)).toEqual({ + count: 2, + orientation: 'horizontal', + thumbnailDisplay: 'large', + thumbnailAlign: 'left', + height: '200px', + }); + }); + + it('returns undefined if the config array is empty or undefined', () => { + expect(getBreakpointConfig([], 5)).toBeUndefined(); + expect(getBreakpointConfig(undefined, 5)).toBeUndefined(); + }); + + it('returns undefined if no matching breakpoints are found', () => { + expect(getBreakpointConfig(config, 1)).toBeUndefined(); + }); +}); diff --git a/lib/cards/gridBaseLayouts.js b/lib/cards/gridBaseLayouts.js index 35b6fdb51..5898d654f 100644 --- a/lib/cards/gridBaseLayouts.js +++ b/lib/cards/gridBaseLayouts.js @@ -3,19 +3,8 @@ * corresponding to the most commonly used grids in our designs. */ -// Breakpoint levels -// Correspond to https://design-system.learningequality.org/layout/#responsiveness -const LEVEL_0 = 'level-0'; -const LEVEL_1 = 'level-1'; -const LEVEL_2 = 'level-2'; -const LEVEL_3 = 'level-3'; -const LEVEL_4 = 'level-4'; -const LEVEL_5 = 'level-5'; -const LEVEL_6 = 'level-6'; -const LEVEL_7 = 'level-7'; - -// Settings common to all breakpoint levels -const levelCommon = { +// Settings common to all breakpoints +const breakpointCommon = { columnGap: '30px', rowGap: '30px', }; @@ -24,41 +13,17 @@ const levelCommon = { * Configuration for '1-1-1' grid, * that is a grid with 1 card per row * on all screen sizes. + * + * Organized by breakpoints as defined in + * https://design-system.learningequality.org/layout/#responsiveness */ -const LAYOUT_CONFIG_1_1_1 = { - [LEVEL_0]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_1]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_2]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_3]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_4]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_5]: { +const LAYOUT_CONFIG_1_1_1 = [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], cardsPerRow: 1, - ...levelCommon, + ...breakpointCommon, }, - [LEVEL_6]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_7]: { - cardsPerRow: 1, - ...levelCommon, - }, -}; +]; /** * Configuration for '1-2-2' grid, @@ -66,41 +31,22 @@ const LAYOUT_CONFIG_1_1_1 = { * - 1 card per row on smaller screens * - 2 cards per row on medium screens * - 2 cards per row on larger screens + * + * Organized by breakpoints as defined in + * https://design-system.learningequality.org/layout/#responsiveness */ -const LAYOUT_CONFIG_1_2_2 = { - [LEVEL_0]: { +const LAYOUT_CONFIG_1_2_2 = [ + { + breakpoints: [0, 1], cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_1]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_2]: { - cardsPerRow: 2, - ...levelCommon, - }, - [LEVEL_3]: { - cardsPerRow: 2, - ...levelCommon, - }, - [LEVEL_4]: { - cardsPerRow: 2, - ...levelCommon, + ...breakpointCommon, }, - [LEVEL_5]: { + { + breakpoints: [2, 3, 4, 5, 6, 7], cardsPerRow: 2, - ...levelCommon, + ...breakpointCommon, }, - [LEVEL_6]: { - cardsPerRow: 2, - ...levelCommon, - }, - [LEVEL_7]: { - cardsPerRow: 2, - ...levelCommon, - }, -}; +]; /** * Configuration for '1-2-3' grid, @@ -108,55 +54,32 @@ const LAYOUT_CONFIG_1_2_2 = { * - 1 card per row on smaller screens * - 2 cards per row on medium screens * - 3 cards per row on larger screens + * + * Organized by breakpoints as defined in + * https://design-system.learningequality.org/layout/#responsiveness */ -const LAYOUT_CONFIG_1_2_3 = { - [LEVEL_0]: { +const LAYOUT_CONFIG_1_2_3 = [ + { + breakpoints: [0, 1], cardsPerRow: 1, - ...levelCommon, + ...breakpointCommon, }, - [LEVEL_1]: { - cardsPerRow: 1, - ...levelCommon, - }, - [LEVEL_2]: { + { + breakpoints: [2, 3], cardsPerRow: 2, - ...levelCommon, + ...breakpointCommon, }, - [LEVEL_3]: { - cardsPerRow: 2, - ...levelCommon, - }, - [LEVEL_4]: { - cardsPerRow: 3, - ...levelCommon, - }, - [LEVEL_5]: { - cardsPerRow: 3, - ...levelCommon, - }, - [LEVEL_6]: { + { + breakpoints: [4, 5, 6, 7], cardsPerRow: 3, - ...levelCommon, + ...breakpointCommon, }, - [LEVEL_7]: { - cardsPerRow: 3, - ...levelCommon, - }, -}; +]; export const LAYOUT_1_1_1 = '1-1-1'; export const LAYOUT_1_2_2 = '1-2-2'; export const LAYOUT_1_2_3 = '1-2-3'; -export const LEVELS = { - 0: LEVEL_0, - 1: LEVEL_1, - 2: LEVEL_2, - 3: LEVEL_3, - 4: LEVEL_4, - 5: LEVEL_5, - 6: LEVEL_6, - 7: LEVEL_7, -}; + export const LAYOUT_CONFIGS = { [LAYOUT_1_1_1]: LAYOUT_CONFIG_1_1_1, [LAYOUT_1_2_2]: LAYOUT_CONFIG_1_2_2, diff --git a/lib/cards/useGridLayout.js b/lib/cards/useGridLayout.js new file mode 100644 index 000000000..dc43a0b3f --- /dev/null +++ b/lib/cards/useGridLayout.js @@ -0,0 +1,67 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { watch, ref, toRefs } from '@vue/composition-api'; + +import useKResponsiveWindow from '../composables/useKResponsiveWindow'; + +import { LAYOUT_CONFIGS } from './gridBaseLayouts'; +import { getBreakpointConfig } from './utils'; + +/** + * Observes window size and returns the grid layout + * configuration object for the current breakpoint. + */ +export default function useGridLayout(props) { + const { layout, layoutOverride } = toRefs(props); + const currentBreakpointConfig = ref({}); + const { windowBreakpoint } = useKResponsiveWindow(); + + /** + * Obtains the base grid layout configuration object + * for the given breakpoint. If `layoutOverride` + * is defined, applies overrides and returns the final + * grid layout configuration. + * + * @param {Object} props `KCardGrid` props + * @param {Number} breakpoint Breakpoint 0-7 + * + * @returns {Object} The final grid layout configuration + * object for the `breakpoint` + */ + function getLayoutConfigForBreakpoint(props, breakpoint) { + if (breakpoint === null || breakpoint === undefined) { + return getLayoutConfigForBreakpoint(props, 0); + } + // Obtain the base layout configuration for the breakpoint + const baseLayoutConfig = LAYOUT_CONFIGS[layout.value]; + const baseBreakpointConfig = getBreakpointConfig(baseLayoutConfig, breakpoint); + + // Deep clone to protect mutating LAYOUT_CONFIGS + const finalBreakpointConfig = cloneDeep(baseBreakpointConfig); + + // Remove `breakpoints` attribute as it's not needed + delete finalBreakpointConfig.breakpoints; + + // Override if `layoutOverride` contains + // settings for the breakpoint + const breakpointOverride = getBreakpointConfig(layoutOverride.value, breakpoint); + if (breakpointOverride) { + for (const key of ['cardsPerRow', 'columnGap', 'rowGap']) { + if (breakpointOverride[key]) { + finalBreakpointConfig[key] = breakpointOverride[key]; + } + } + } + + return finalBreakpointConfig; + } + + watch( + [windowBreakpoint, layout, layoutOverride], + ([newBreakpoint]) => { + currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, newBreakpoint); + }, + { immediate: true, deep: true } + ); + + return { currentBreakpointConfig, windowBreakpoint }; +} diff --git a/lib/cards/useGridLoading.js b/lib/cards/useGridLoading.js new file mode 100644 index 000000000..aa6683a02 --- /dev/null +++ b/lib/cards/useGridLoading.js @@ -0,0 +1,141 @@ +import Vue from 'vue'; +import { ref, watch, onMounted, toRefs, computed } from '@vue/composition-api'; + +import useGridLayout from './useGridLayout'; +import { getBreakpointConfig } from './utils'; + +// The skeleton loaders will be displayed after `LOADING_DELAY` +// for a duration of `MIN_LOADING_TIME` +// (https://www.nngroup.com/articles/skeleton-screens/) +const LOADING_DELAY = 1000; +const MIN_LOADING_TIME = 1000; + +const DEFAULT_SKELETON = { + count: undefined, // default determined by the grid layout and the current breakpoint + height: '200px', + orientation: 'horizontal', + thumbnailDisplay: 'none', + thumbnailAlign: 'left', +}; + +/** + * Manages `KCardGrid`'s loading state + */ +export default function useGridLoading(props) { + const { currentBreakpointConfig, windowBreakpoint } = useGridLayout(props); + const { loading, skeletonsConfig } = toRefs(props); + + const skeletonCount = ref(DEFAULT_SKELETON.count); + const skeletonHeight = ref(DEFAULT_SKELETON.height); + const skeletonOrientation = ref(DEFAULT_SKELETON.orientation); + const skeletonThumbnailDisplay = ref(DEFAULT_SKELETON.thumbnailDisplay); + const skeletonThumbnailAlign = ref(DEFAULT_SKELETON.thumbnailAlign); + + const isLoading = ref(false); // Used by `KCardGrid` to determine whether to display loading skeletons + const finishedMounting = ref(false); + const isLoadingDelayActive = ref(false); + let loadingDelayTimeout = null; + let loadingStartTime = null; + let loadingElapsedTime = null; + let remainingLoadingTime = 0; + + // Handles `KCardGrid`'s `loading` prop changes and returns + // final `isLoading` state to be used by `KCardGrid` + watch( + loading, + (newLoading, oldLoading) => { + if (newLoading === oldLoading) { + return; + } + + // if loading started, delay it + if (newLoading) { + isLoadingDelayActive.value = true; + loadingDelayTimeout = setTimeout(() => { + loadingStartTime = Date.now(); + isLoading.value = true; + isLoadingDelayActive.value = false; + }, LOADING_DELAY); + } + + // if loading finished before the loading delay completed, + // cancel display of the loading state + if (!newLoading && !loadingStartTime) { + isLoadingDelayActive.value = false; + clearTimeout(loadingDelayTimeout); + } + + // if loading finished some time after the loading delay completed, + // ensure that the loading state is visible for at least `MIN_LOADING_TIME` + if (!newLoading && loadingStartTime) { + loadingElapsedTime = Date.now() - loadingStartTime; + if (loadingElapsedTime < MIN_LOADING_TIME) { + remainingLoadingTime = MIN_LOADING_TIME - loadingElapsedTime; + } else { + remainingLoadingTime = 0; + } + + setTimeout(() => { + isLoading.value = false; + + loadingStartTime = null; + loadingElapsedTime = null; + remainingLoadingTime = 0; + }, remainingLoadingTime); + } + }, + { immediate: true } + ); + + // Used by `KCardGrid` to prevent jarring UX: + // - (1) prevents flashes of unstyled content during the mouning stage + // - (2) prevents uncomplete cards from being displayed during the loading delay period + const showGrid = computed(() => { + return finishedMounting.value && !isLoadingDelayActive.value; + }); + + onMounted(() => { + Vue.nextTick(() => { + finishedMounting.value = true; + }); + }); + + // Updates the loading skeleton configuration + //for the current breakpoint + watch( + [windowBreakpoint, skeletonsConfig, currentBreakpointConfig], + ([newBreakpoint]) => { + skeletonCount.value = currentBreakpointConfig.value.cardsPerRow; + + const breakpointSkeletonConfig = getBreakpointConfig(skeletonsConfig.value, newBreakpoint); + if (breakpointSkeletonConfig) { + if (breakpointSkeletonConfig.count) { + skeletonCount.value = breakpointSkeletonConfig.count; + } + if (breakpointSkeletonConfig.height) { + skeletonHeight.value = breakpointSkeletonConfig.height; + } + if (breakpointSkeletonConfig.orientation) { + skeletonOrientation.value = breakpointSkeletonConfig.orientation; + } + if (breakpointSkeletonConfig.thumbnailDisplay) { + skeletonThumbnailDisplay.value = breakpointSkeletonConfig.thumbnailDisplay; + } + if (breakpointSkeletonConfig.thumbnailAlign) { + skeletonThumbnailAlign.value = breakpointSkeletonConfig.thumbnailAlign; + } + } + }, + { immediate: true, deep: true } + ); + + return { + showGrid, + isLoading, + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, + }; +} diff --git a/lib/cards/useResponsiveGridLayout.js b/lib/cards/useResponsiveGridLayout.js deleted file mode 100644 index 0fecebc1b..000000000 --- a/lib/cards/useResponsiveGridLayout.js +++ /dev/null @@ -1,47 +0,0 @@ -import { watch, ref } from '@vue/composition-api'; - -import useKResponsiveWindow from '../composables/useKResponsiveWindow'; - -import { LAYOUT_CONFIGS, LEVELS } from './gridBaseLayouts'; - -/** - * Observes the window breakpoint level - * and returns the grid layout configuration - * object for the current breakpoint level. - */ -export default function useResponsiveGridLayout(props) { - const currentLevelConfig = ref({}); - - const { windowBreakpoint } = useKResponsiveWindow(); - - /** - * - * @param {Object} props `KCardGrid` props - * @param {Number} breakpoint The breakpoint level 0-7 - * - * @returns {Object} The grid layout configuration object - * for the given breakpoint level - */ - function getLevelLayoutConfig(props, breakpoint) { - const baseLayoutConfig = LAYOUT_CONFIGS[props.layout]; - const baseLevelConfig = baseLayoutConfig[LEVELS[breakpoint]]; - - return { ...baseLevelConfig }; - } - - watch( - windowBreakpoint, - (newBreakpoint, oldBreakpoint) => { - // can happen very briefly before the breakpoint value gets calculated - if (newBreakpoint === null) { - currentLevelConfig.value = getLevelLayoutConfig(props, 0); - } - if (newBreakpoint !== oldBreakpoint) { - currentLevelConfig.value = getLevelLayoutConfig(props, newBreakpoint); - } - }, - { immediate: true } - ); - - return { currentLevelConfig }; -} diff --git a/lib/cards/utils.js b/lib/cards/utils.js new file mode 100644 index 000000000..d8809a5ba --- /dev/null +++ b/lib/cards/utils.js @@ -0,0 +1,35 @@ +/** + * Get final configuration object for a breakpoint + * + * @param {Array} config A configuration in the same format as in LAYOUT_CONFIGS + * @param {Number} breakpoint 0-7 + * + * @returns {Object} The configuration object for the `breakpoint` + */ +export function getBreakpointConfig(config, breakpoint) { + if (!config || !config.length) { + return undefined; + } + + // The same breakpoint can be used in multiple configuration + // objects so here filter all matching configurations objects + // for the breakpoint... + const matchingConfigs = config.filter( + breakpointConfig => + breakpointConfig.breakpoints && breakpointConfig.breakpoints.includes(breakpoint) + ); + + // ...and populate all configurations related to the breakpoint + // to its final configuration object. + // If there are competing values, give preference to the last one + const result = {}; + matchingConfigs.forEach(breakpointConfig => { + Object.keys(breakpointConfig).forEach(key => { + if (key !== 'breakpoints') { + result[key] = breakpointConfig[key]; + } + }); + }); + + return Object.keys(result).length ? result : undefined; +}