From d379fb88e9588d43c86a2c1c47496a2613856cf0 Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:13:58 +0200 Subject: [PATCH 01/14] Final format for config objects This simplifies config object format and related language around breakpoints. Prepares ground for `layoutOverride` and `skeletonsConfig` public API that will have the same format. --- lib/cards/KCardGrid.vue | 4 +- lib/cards/gridBaseLayouts.js | 149 +++++++-------------------- lib/cards/useResponsiveGridLayout.js | 31 +++--- 3 files changed, 55 insertions(+), 129 deletions(-) diff --git a/lib/cards/KCardGrid.vue b/lib/cards/KCardGrid.vue index c8abdc7ae..0ecba4f87 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -30,13 +30,13 @@ name: 'KCardGrid', setup(props) { - const { currentLevelConfig } = useResponsiveGridLayout(props); + const { currentBreakpointConfig } = useResponsiveGridLayout(props); const gridStyle = ref({}); const gridItemStyle = ref({}); watch( - currentLevelConfig, + currentBreakpointConfig, newValue => { const { cardsPerRow, columnGap, rowGap } = newValue; 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/useResponsiveGridLayout.js b/lib/cards/useResponsiveGridLayout.js index 0fecebc1b..a1bc3dc81 100644 --- a/lib/cards/useResponsiveGridLayout.js +++ b/lib/cards/useResponsiveGridLayout.js @@ -2,31 +2,34 @@ import { watch, ref } from '@vue/composition-api'; import useKResponsiveWindow from '../composables/useKResponsiveWindow'; -import { LAYOUT_CONFIGS, LEVELS } from './gridBaseLayouts'; +import { LAYOUT_CONFIGS } from './gridBaseLayouts'; /** - * Observes the window breakpoint level - * and returns the grid layout configuration - * object for the current breakpoint level. + * Observes window size and returns the grid layout + * configuration object for the current breakpoint. */ export default function useResponsiveGridLayout(props) { - const currentLevelConfig = ref({}); + const currentBreakpointConfig = ref({}); const { windowBreakpoint } = useKResponsiveWindow(); + function getBreakpointConfig(config, breakpoint) { + const breakpointConfig = config.find(subConfig => subConfig.breakpoints.includes(breakpoint)); + return breakpointConfig; + } + /** * * @param {Object} props `KCardGrid` props - * @param {Number} breakpoint The breakpoint level 0-7 + * @param {Number} breakpoint Breakpoint 0-7 * * @returns {Object} The grid layout configuration object - * for the given breakpoint level + * for the `breakpoint` */ - function getLevelLayoutConfig(props, breakpoint) { + function getLayoutConfigForBreakpoint(props, breakpoint) { const baseLayoutConfig = LAYOUT_CONFIGS[props.layout]; - const baseLevelConfig = baseLayoutConfig[LEVELS[breakpoint]]; - - return { ...baseLevelConfig }; + const baseBreakpointConfig = getBreakpointConfig(baseLayoutConfig, breakpoint); + return { ...baseBreakpointConfig }; } watch( @@ -34,14 +37,14 @@ export default function useResponsiveGridLayout(props) { (newBreakpoint, oldBreakpoint) => { // can happen very briefly before the breakpoint value gets calculated if (newBreakpoint === null) { - currentLevelConfig.value = getLevelLayoutConfig(props, 0); + currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, 0); } if (newBreakpoint !== oldBreakpoint) { - currentLevelConfig.value = getLevelLayoutConfig(props, newBreakpoint); + currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, newBreakpoint); } }, { immediate: true } ); - return { currentLevelConfig }; + return { currentBreakpointConfig }; } From b1474205882b85f2c6da2ecea067c01d8506a7ef Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:05 +0200 Subject: [PATCH 02/14] Add option to override layout --- docs/pages/kcardgrid.vue | 83 ++++++++++++++++++++++++++-- lib/cards/KCardGrid.vue | 20 +++++-- lib/cards/useResponsiveGridLayout.js | 64 ++++++++++++++++----- 3 files changed, 143 insertions(+), 24 deletions(-) diff --git a/docs/pages/kcardgrid.vue b/docs/pages/kcardgrid.vue index ac5293981..a7d0bdb46 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.

@@ -37,7 +37,7 @@ -

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
  • +
  • cardsPerRow overrides the number of cards per row for specified breakpoints
  • +
  • columnGap/rowGap overrides grid column/row gaps for 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 @@ -536,7 +599,19 @@ return { windowBreakpoint }; }, data() { - return {}; + return { + layoutOverride: [ + { + breakpoints: [0, 1], + columnGap: '20px', + rowGap: '20px', + }, + { + breakpoints: [4, 5, 6, 7], + cardsPerRow: 4, + }, + ], + }; }, computed: { slicedPills() { diff --git a/lib/cards/KCardGrid.vue b/lib/cards/KCardGrid.vue index 0ecba4f87..97228295a 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -20,11 +20,6 @@ /** * 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', @@ -38,6 +33,10 @@ watch( currentBreakpointConfig, newValue => { + if (!newValue) { + return; + } + const { cardsPerRow, columnGap, rowGap } = newValue; gridStyle.value = { @@ -77,6 +76,17 @@ 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 }, }; diff --git a/lib/cards/useResponsiveGridLayout.js b/lib/cards/useResponsiveGridLayout.js index a1bc3dc81..845e02b75 100644 --- a/lib/cards/useResponsiveGridLayout.js +++ b/lib/cards/useResponsiveGridLayout.js @@ -1,3 +1,4 @@ +import cloneDeep from 'lodash/cloneDeep'; import { watch, ref } from '@vue/composition-api'; import useKResponsiveWindow from '../composables/useKResponsiveWindow'; @@ -13,37 +14,70 @@ export default function useResponsiveGridLayout(props) { const { windowBreakpoint } = useKResponsiveWindow(); + /** + * Get 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 corresponding to the `breakpoint` + */ function getBreakpointConfig(config, breakpoint) { - const breakpointConfig = config.find(subConfig => subConfig.breakpoints.includes(breakpoint)); - return breakpointConfig; + if (!config || !config.length) { + return undefined; + } + return config.find( + subConfig => subConfig.breakpoints && subConfig.breakpoints.includes(breakpoint) + ); } /** + * 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 grid layout configuration object - * for the `breakpoint` + * @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[props.layout]; const baseBreakpointConfig = getBreakpointConfig(baseLayoutConfig, breakpoint); - return { ...baseBreakpointConfig }; + + // 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(props.layoutOverride, breakpoint); + if (breakpointOverride) { + for (const key of ['cardsPerRow', 'columnGap', 'rowGap']) { + if (breakpointOverride[key]) { + finalBreakpointConfig[key] = breakpointOverride[key]; + } + } + } + + return finalBreakpointConfig; } + // Watch props too to make `layout` and `layoutOverride` reactive watch( - windowBreakpoint, - (newBreakpoint, oldBreakpoint) => { - // can happen very briefly before the breakpoint value gets calculated - if (newBreakpoint === null) { - currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, 0); - } - if (newBreakpoint !== oldBreakpoint) { - currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, newBreakpoint); - } + [windowBreakpoint, props], + ([newBreakpoint]) => { + currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, newBreakpoint); }, - { immediate: true } + { immediate: true, deep: true } ); return { currentBreakpointConfig }; From 0abf584a1cc8e3e6840a930ca7e8bec72f6682b3 Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:12 +0200 Subject: [PATCH 03/14] Add loading skeletons Also removes progressive loading experience from card placeholder since loading state is now handled by loading skeletons and the previous logic causes unneccessary hiccup when the placeholder is displayed very briefly before the image. --- docs/pages/kcard.vue | 184 +++++++++++++++- docs/pages/kcardgrid.vue | 306 +++++++++++++++++++++++++-- lib/cards/KCard.vue | 84 ++++---- lib/cards/KCardGrid.vue | 140 +++++++++++- lib/cards/SkeletonCard.vue | 133 ++++++++++++ lib/cards/__tests__/KCard.spec.js | 25 +-- lib/cards/__tests__/utils.spec.js | 66 ++++++ lib/cards/useGridLoading.js | 118 +++++++++++ lib/cards/useResponsiveGridLayout.js | 20 +- lib/cards/utils.js | 35 +++ 10 files changed, 996 insertions(+), 115 deletions(-) create mode 100644 lib/cards/SkeletonCard.vue create mode 100644 lib/cards/__tests__/utils.spec.js create mode 100644 lib/cards/useGridLoading.js create mode 100644 lib/cards/utils.js diff --git a/docs/pages/kcard.vue b/docs/pages/kcard.vue index e2140295a..c882a287e 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; + }, 500); + }, }; diff --git a/docs/pages/kcardgrid.vue b/docs/pages/kcardgrid.vue index a7d0bdb46..d94918537 100644 --- a/docs/pages/kcardgrid.vue +++ b/docs/pages/kcardgrid.vue @@ -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 .

    @@ -40,7 +41,7 @@ { text: 'Layout customization', href: '#layout-customization' }, { text: 'Card height, content tolerance and alignment', href: '#card-height-and-alignment' }, { text: 'Fine-tuning responsiveness', href: '#fine-tuning-responsiveness' }, - { text: 'Loading state (TBD)', href: '#loading-state' }, + { text: 'Loading state', href: '#loading-state' }, ]" /> @@ -85,7 +86,11 @@ - + - + - + 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
    • -
    • cardsPerRow overrides the number of cards per row for specified breakpoints
    • -
    • columnGap/rowGap overrides grid column/row gaps for specified breakpoints
    • +
    • 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:

    @@ -236,6 +249,8 @@ @@ -296,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. KCardGrid ensures a minimum display time for the loading state to prevent a jarring user experience when data loads quickly.

    + +

    Use the skeletonsConfig prop to configure skeleton cards to match the expected visual output of loaded cards on all screen sizes. For easier development, enable the debug prop to display the current breakpoint in the top left corner of the grid. 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.
    • +
    • 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 example:

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

    Here, 3 skeleton cards are shown across all breakpoints. Their height 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.

    + + +

    Simplify skeletonsConfig by taking a bottom-up approach. Begin with a base setup for all breakpoints and override only where needed. For example, the above configuration can be written as:

    + + + + export default { + ... + data() { + return { + skeletonsConfig: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 3, + height: '400px', + orientation: 'vertical', + thumbnailDisplay: 'large', + thumbnailAlign: 'left' + }, + { + breakpoints: [4, 5, 6, 7], + height: '220px', + orientation: 'horizontal' + } + ], + }; + }, + }; + + +
    + +

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

    @@ -600,6 +752,121 @@ }, data() { return { + debug: false, + loading: true, + skeletonsConfig1: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 2, + 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], + count: 3, + 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], + count: 5, + 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], + count: 6, + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '360px', + }, + ], + skeletonsConfig5: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 3, + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '420px', + }, + { + breakpoints: [3, 4, 5, 6, 7], + height: '390px', + }, + ], + skeletonsConfig6: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 2, + 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], + count: 2, + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '430px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '370px', + }, + ], + skeletonsConfig8: [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 3, + orientation: 'vertical', + thumbnailDisplay: 'large', + height: '400px', + }, + { + breakpoints: [4, 5, 6, 7], + height: '220px', + orientation: 'horizontal', + thumbnailAlign: 'left', + }, + ], layoutOverride: [ { breakpoints: [0, 1], @@ -618,6 +885,19 @@ return ['Short Activity', 'Biology', 'Ecology', 'Ornithology'].slice(0, 2); }, }, + mounted() { + setTimeout(() => { + this.loading = false; + }, 500); + }, + methods: { + reload() { + this.loading = true; + setTimeout(() => { + this.loading = false; + }, 500); + }, + }, }; diff --git a/lib/cards/KCard.vue b/lib/cards/KCard.vue index 286ac896d..98745b324 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. // @@ -594,6 +586,8 @@ } .link { + display: inline-block; // allows title placeholder in the skeleton card + width: 100%; // allows title placeholder in the skeleton card text-decoration: none; outline: none; // the focus ring is moved to the whole
  • } @@ -699,21 +693,21 @@ $thumbnail-width: null; /* - Coordinates space taken by the thumbnail area and the content area - next to it more intelligently in browsers that support `clamp()` by: + Coordinates space taken by the thumbnail area and the content area + next to it more intelligently in browsers that support `clamp()` by: - - Instead of defining 'width', 'min-width', and 'max-width' separately, - `clamp()` is used with the goal to have the actual thumbnail width - saved in the single `$thumbnail-width` value. + - Instead of defining 'width', 'min-width', and 'max-width' separately, + `clamp()` is used with the goal to have the actual thumbnail width + saved in the single `$thumbnail-width` value. - - The `$thumbnail-width` value is then referenced when calculating - the remaining space for the content area, ensuring the precise - distribution of space. + - The `$thumbnail-width` value is then referenced when calculating + the remaining space for the content area, ensuring the precise + distribution of space. - Resolves some issues related to unprecise calculations, most importantly - this removes the area of empty space between the thumbnail and content areas - in some card's sizes, wasting space that can be used for card's textual content. - */ + Resolves some issues related to unprecise calculations, most importantly + this removes the area of empty space between the thumbnail and content areas + in some card's sizes, wasting space that can be used for card's textual content. + */ @mixin clamp-with-fallback($min, $preferred, $max) { // fallback for browsers that don't support 'clamp()' $thumbnail-width: $preferred; diff --git a/lib/cards/KCardGrid.vue b/lib/cards/KCardGrid.vue index 97228295a..2102f2ced 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -1,12 +1,51 @@ @@ -17,15 +56,28 @@ import { LAYOUT_1_1_1, LAYOUT_1_2_2, LAYOUT_1_2_3 } from './gridBaseLayouts'; import useResponsiveGridLayout from './useResponsiveGridLayout'; + import useGridLoading from './useGridLoading'; + import SkeletonCard from './SkeletonCard'; /** * Displays a grid of cards `KCard`. */ export default { name: 'KCardGrid', - + components: { + SkeletonCard, + }, setup(props) { - const { currentBreakpointConfig } = useResponsiveGridLayout(props); + const { currentBreakpointConfig, windowBreakpoint } = useResponsiveGridLayout(props); + const { + finishedMounting, + isLoading, + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, + } = useGridLoading(props); const gridStyle = ref({}); const gridItemStyle = ref({}); @@ -59,7 +111,15 @@ provide('gridItemStyle', gridItemStyle); return { + windowBreakpoint, gridStyle, + isLoading, + finishedMounting, + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, }; }, props: { @@ -87,6 +147,36 @@ 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, + }, }, }; @@ -95,7 +185,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..4e882bce1 --- /dev/null +++ b/lib/cards/SkeletonCard.vue @@ -0,0 +1,133 @@ + + + + + + + \ 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/useGridLoading.js b/lib/cards/useGridLoading.js new file mode 100644 index 000000000..27f5c0228 --- /dev/null +++ b/lib/cards/useGridLoading.js @@ -0,0 +1,118 @@ +import Vue from 'vue'; +import { ref, watch, onMounted } from '@vue/composition-api'; + +import useKResponsiveWindow from '../composables/useKResponsiveWindow'; +import { getBreakpointConfig } from './utils'; + +// Loading state lasts for at least this duration. +const MIN_LOADING_TIME = 2000; + +const DEFAULT_SKELETON = { + count: 3, + height: '200px', + orientation: 'horizontal', + thumbnailDisplay: 'none', + thumbnailAlign: 'left', +}; + +/** + * Manages `KCardGrid`'s loading state + */ +export default function useGridLoading(props) { + const { windowBreakpoint } = useKResponsiveWindow(); + + 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); + + // Used by `KCardGrid` to prevent flashes of unstyled content + const finishedMounting = ref(false); + onMounted(() => { + Vue.nextTick(() => { + finishedMounting.value = true; + }); + }); + + // Handles `KCardGrid`'s `loading` prop changes and returns + // final `isLoading` state to be used by `KCardGrid`. + // + // After loading started, `isLoading` ensures that + // loading state is truthy for at least `MIN_LOADING_TIME` + // to avoid unexpected flashes during the transition. + let loadingStartTime = null; + let loadingElapsedTime = null; + let remainingLoadingTime = 0; + const isLoading = ref(false); + + watch( + [() => props.loading, finishedMounting], + ([newLoading, newFinishedMounting]) => { + if (newLoading === true) { + loadingStartTime = Date.now(); + isLoading.value = true; + } + + if (newFinishedMounting && loadingStartTime) { + // if loading was started before mounting, + // move loading start time forward to + // ensure that minimum loading time is respected + loadingStartTime = Date.now(); + + if (newLoading === false) { + loadingElapsedTime = Date.now() - loadingStartTime; + + if (loadingElapsedTime < MIN_LOADING_TIME) { + remainingLoadingTime = MIN_LOADING_TIME - loadingElapsedTime; + } else { + remainingLoadingTime = 0; + } + + setTimeout(() => { + loadingStartTime = null; + isLoading.value = false; + }, remainingLoadingTime); + } + } + }, + { immediate: true } + ); + + // Observes window screen size and updates the loading + // skeleton configuration for the current breakpoint + watch( + windowBreakpoint, + newBreakpoint => { + const breakpointSkeletonconfig = getBreakpointConfig(props.skeletonsConfig, 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 } + ); + + return { + finishedMounting, + isLoading, + skeletonCount, + skeletonHeight, + skeletonOrientation, + skeletonThumbnailDisplay, + skeletonThumbnailAlign, + }; +} diff --git a/lib/cards/useResponsiveGridLayout.js b/lib/cards/useResponsiveGridLayout.js index 845e02b75..45a2962e0 100644 --- a/lib/cards/useResponsiveGridLayout.js +++ b/lib/cards/useResponsiveGridLayout.js @@ -4,6 +4,7 @@ import { watch, ref } 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 @@ -14,23 +15,6 @@ export default function useResponsiveGridLayout(props) { const { windowBreakpoint } = useKResponsiveWindow(); - /** - * Get 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 corresponding to the `breakpoint` - */ - function getBreakpointConfig(config, breakpoint) { - if (!config || !config.length) { - return undefined; - } - return config.find( - subConfig => subConfig.breakpoints && subConfig.breakpoints.includes(breakpoint) - ); - } - /** * Obtains the base grid layout configuration object * for the given breakpoint. If `layoutOverride` @@ -80,5 +64,5 @@ export default function useResponsiveGridLayout(props) { { immediate: true, deep: true } ); - return { currentBreakpointConfig }; + return { currentBreakpointConfig, windowBreakpoint }; } 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; +} From e4535b29187165189001eaca660d423ed82d11fc Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:16 +0200 Subject: [PATCH 04/14] Rename composable to simpler name that's also consistent with the other grid composable's name. --- lib/cards/KCardGrid.vue | 4 ++-- lib/cards/{useResponsiveGridLayout.js => useGridLayout.js} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename lib/cards/{useResponsiveGridLayout.js => useGridLayout.js} (97%) diff --git a/lib/cards/KCardGrid.vue b/lib/cards/KCardGrid.vue index 2102f2ced..4a6f41373 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -55,7 +55,7 @@ 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'; @@ -68,7 +68,7 @@ SkeletonCard, }, setup(props) { - const { currentBreakpointConfig, windowBreakpoint } = useResponsiveGridLayout(props); + const { currentBreakpointConfig, windowBreakpoint } = useGridLayout(props); const { finishedMounting, isLoading, diff --git a/lib/cards/useResponsiveGridLayout.js b/lib/cards/useGridLayout.js similarity index 97% rename from lib/cards/useResponsiveGridLayout.js rename to lib/cards/useGridLayout.js index 45a2962e0..06047ed1e 100644 --- a/lib/cards/useResponsiveGridLayout.js +++ b/lib/cards/useGridLayout.js @@ -10,7 +10,7 @@ import { getBreakpointConfig } from './utils'; * Observes window size and returns the grid layout * configuration object for the current breakpoint. */ -export default function useResponsiveGridLayout(props) { +export default function useGridLayout(props) { const currentBreakpointConfig = ref({}); const { windowBreakpoint } = useKResponsiveWindow(); From 874056a8507cae2464905e7c51021c1ee56120dd Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:19 +0200 Subject: [PATCH 05/14] Use a single place for link style and remove duplicate value. --- lib/cards/KCard.vue | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/cards/KCard.vue b/lib/cards/KCard.vue index 98745b324..82efb2e48 100644 --- a/lib/cards/KCard.vue +++ b/lib/cards/KCard.vue @@ -548,11 +548,6 @@ font-size: 16px; font-weight: 600; line-height: 1.5; - - a { - color: inherit; - text-decoration: none; - } } .thumbnail { @@ -588,6 +583,7 @@ .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
  • } From f6e5321c4f7bfd0f39fc16e1fd24f336d31daabd Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:22 +0200 Subject: [PATCH 06/14] Deconstruct props and make them reactive --- lib/cards/useGridLayout.js | 11 +++++------ lib/cards/useGridLoading.js | 17 +++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/cards/useGridLayout.js b/lib/cards/useGridLayout.js index 06047ed1e..dc43a0b3f 100644 --- a/lib/cards/useGridLayout.js +++ b/lib/cards/useGridLayout.js @@ -1,5 +1,5 @@ import cloneDeep from 'lodash/cloneDeep'; -import { watch, ref } from '@vue/composition-api'; +import { watch, ref, toRefs } from '@vue/composition-api'; import useKResponsiveWindow from '../composables/useKResponsiveWindow'; @@ -11,8 +11,8 @@ import { getBreakpointConfig } from './utils'; * configuration object for the current breakpoint. */ export default function useGridLayout(props) { + const { layout, layoutOverride } = toRefs(props); const currentBreakpointConfig = ref({}); - const { windowBreakpoint } = useKResponsiveWindow(); /** @@ -32,7 +32,7 @@ export default function useGridLayout(props) { return getLayoutConfigForBreakpoint(props, 0); } // Obtain the base layout configuration for the breakpoint - const baseLayoutConfig = LAYOUT_CONFIGS[props.layout]; + const baseLayoutConfig = LAYOUT_CONFIGS[layout.value]; const baseBreakpointConfig = getBreakpointConfig(baseLayoutConfig, breakpoint); // Deep clone to protect mutating LAYOUT_CONFIGS @@ -43,7 +43,7 @@ export default function useGridLayout(props) { // Override if `layoutOverride` contains // settings for the breakpoint - const breakpointOverride = getBreakpointConfig(props.layoutOverride, breakpoint); + const breakpointOverride = getBreakpointConfig(layoutOverride.value, breakpoint); if (breakpointOverride) { for (const key of ['cardsPerRow', 'columnGap', 'rowGap']) { if (breakpointOverride[key]) { @@ -55,9 +55,8 @@ export default function useGridLayout(props) { return finalBreakpointConfig; } - // Watch props too to make `layout` and `layoutOverride` reactive watch( - [windowBreakpoint, props], + [windowBreakpoint, layout, layoutOverride], ([newBreakpoint]) => { currentBreakpointConfig.value = getLayoutConfigForBreakpoint(props, newBreakpoint); }, diff --git a/lib/cards/useGridLoading.js b/lib/cards/useGridLoading.js index 27f5c0228..5164c3830 100644 --- a/lib/cards/useGridLoading.js +++ b/lib/cards/useGridLoading.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { ref, watch, onMounted } from '@vue/composition-api'; +import { ref, watch, onMounted, toRefs } from '@vue/composition-api'; import useKResponsiveWindow from '../composables/useKResponsiveWindow'; import { getBreakpointConfig } from './utils'; @@ -19,6 +19,7 @@ const DEFAULT_SKELETON = { * Manages `KCardGrid`'s loading state */ export default function useGridLoading(props) { + const { loading, skeletonsConfig } = toRefs(props); const { windowBreakpoint } = useKResponsiveWindow(); const skeletonCount = ref(DEFAULT_SKELETON.count); @@ -47,7 +48,7 @@ export default function useGridLoading(props) { const isLoading = ref(false); watch( - [() => props.loading, finishedMounting], + [loading, finishedMounting], ([newLoading, newFinishedMounting]) => { if (newLoading === true) { loadingStartTime = Date.now(); @@ -79,12 +80,12 @@ export default function useGridLoading(props) { { immediate: true } ); - // Observes window screen size and updates the loading - // skeleton configuration for the current breakpoint + // Updates the loading skeleton configuration + //for the current breakpoint watch( - windowBreakpoint, - newBreakpoint => { - const breakpointSkeletonconfig = getBreakpointConfig(props.skeletonsConfig, newBreakpoint); + [windowBreakpoint, skeletonsConfig], + ([newBreakpoint]) => { + const breakpointSkeletonconfig = getBreakpointConfig(skeletonsConfig.value, newBreakpoint); if (breakpointSkeletonconfig) { if (breakpointSkeletonconfig.count) { skeletonCount.value = breakpointSkeletonconfig.count; @@ -103,7 +104,7 @@ export default function useGridLoading(props) { } } }, - { immediate: true } + { immediate: true, deep: true } ); return { From f53573b97361f68c1fad185dff456656bd3921ca Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:26 +0200 Subject: [PATCH 07/14] Lint --- lib/cards/KCard.vue | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/cards/KCard.vue b/lib/cards/KCard.vue index 82efb2e48..29eaeb4db 100644 --- a/lib/cards/KCard.vue +++ b/lib/cards/KCard.vue @@ -689,21 +689,21 @@ $thumbnail-width: null; /* - Coordinates space taken by the thumbnail area and the content area - next to it more intelligently in browsers that support `clamp()` by: + Coordinates space taken by the thumbnail area and the content area + next to it more intelligently in browsers that support `clamp()` by: - - Instead of defining 'width', 'min-width', and 'max-width' separately, - `clamp()` is used with the goal to have the actual thumbnail width - saved in the single `$thumbnail-width` value. + - Instead of defining 'width', 'min-width', and 'max-width' separately, + `clamp()` is used with the goal to have the actual thumbnail width + saved in the single `$thumbnail-width` value. - - The `$thumbnail-width` value is then referenced when calculating - the remaining space for the content area, ensuring the precise - distribution of space. + - The `$thumbnail-width` value is then referenced when calculating + the remaining space for the content area, ensuring the precise + distribution of space. - Resolves some issues related to unprecise calculations, most importantly - this removes the area of empty space between the thumbnail and content areas - in some card's sizes, wasting space that can be used for card's textual content. - */ + Resolves some issues related to unprecise calculations, most importantly + this removes the area of empty space between the thumbnail and content areas + in some card's sizes, wasting space that can be used for card's textual content. + */ @mixin clamp-with-fallback($min, $preferred, $max) { // fallback for browsers that don't support 'clamp()' $thumbnail-width: $preferred; From aaac7b657db1b6473495cd1cda87222f2a7e12c6 Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Sun, 6 Oct 2024 22:14:30 +0200 Subject: [PATCH 08/14] Do not suppres line breaks to allow text in tag be wrapped. --- docs/assets/main.scss | 1 - 1 file changed, 1 deletion(-) 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; } From 4bd1184ac193791265d324e259d8803f74bdec56 Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Tue, 15 Oct 2024 12:25:22 +0200 Subject: [PATCH 09/14] Incorporate design feedback for loading skeletons --- docs/pages/kcard.vue | 2 +- docs/pages/kcardgrid.vue | 43 ++++++++++++--- lib/cards/KCardGrid.vue | 15 ++---- lib/cards/SkeletonCard.vue | 2 +- lib/cards/useGridLoading.js | 104 +++++++++++++++++++++--------------- 5 files changed, 102 insertions(+), 64 deletions(-) diff --git a/docs/pages/kcard.vue b/docs/pages/kcard.vue index c882a287e..c5f6d75b1 100644 --- a/docs/pages/kcard.vue +++ b/docs/pages/kcard.vue @@ -874,7 +874,7 @@ mounted() { setTimeout(() => { this.loading = false; - }, 500); + }, 3000); }, }; diff --git a/docs/pages/kcardgrid.vue b/docs/pages/kcardgrid.vue index d94918537..e809baec6 100644 --- a/docs/pages/kcardgrid.vue +++ b/docs/pages/kcardgrid.vue @@ -593,9 +593,18 @@ -

    While data is loading, KCardGrid shows loading skeleton cards. Use the loading prop to toggle the loading state. KCardGrid ensures a minimum display time for the loading state to prevent a jarring user experience when data loads quickly.

    +

    While data is loading, KCardGrid shows loading skeleton cards. Use the loading prop to toggle the loading state. KCardGrid optimizes the loading experience by:

    -

    Use the skeletonsConfig prop to configure skeleton cards to match the expected visual output of loaded cards on all screen sizes. For easier development, enable the debug prop to display the current breakpoint in the top left corner of the grid. Preview the layout and height of cards with loaded data and adjust skeletonsConfig accordingly.

    +
      +
    • The loading skeletons are not displayed for short loading times (< 1s)
    • +
    • When the loading skeletons are displayed, they are visible for at least 1s
    • +
    + +

    Use the buttons in the example below to preview.

    + +

    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:

    @@ -608,16 +617,22 @@
  • thumbnailAlign sets the thumbnail alignment of skeleton cards for the specified breakpoints. Corresponds to .
  • -

    For example:

    +

    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' }} - - Reload -
    @@ -888,15 +903,27 @@ mounted() { setTimeout(() => { this.loading = false; - }, 500); + }, 3000); }, methods: { - reload() { + 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/KCardGrid.vue b/lib/cards/KCardGrid.vue index 4a6f41373..d7ef459bc 100644 --- a/lib/cards/KCardGrid.vue +++ b/lib/cards/KCardGrid.vue @@ -1,16 +1,7 @@