@comicrelief/component-library 8.51.7 → 8.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/Atoms/Icons/Cross.js +40 -0
  2. package/dist/components/Atoms/Picture/Picture.js +3 -1
  3. package/dist/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.js +11 -7
  4. package/dist/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.md +42 -0
  5. package/dist/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.style.js +23 -33
  6. package/dist/components/Molecules/CTA/CTAMultiCard/__snapshots__/CTAMultiCard.test.js.snap +12 -12
  7. package/dist/components/Organisms/DynamicGallery/DynamicGallery.js +218 -0
  8. package/dist/components/Organisms/DynamicGallery/DynamicGallery.md +30 -0
  9. package/dist/components/Organisms/DynamicGallery/DynamicGallery.style.js +97 -0
  10. package/dist/components/Organisms/DynamicGallery/DynamicGallery.test.js +33 -0
  11. package/dist/components/Organisms/DynamicGallery/_DynamicGalleryColumn.js +111 -0
  12. package/dist/components/Organisms/DynamicGallery/_Lightbox.js +218 -0
  13. package/dist/components/Organisms/DynamicGallery/_Lightbox.style.js +86 -0
  14. package/dist/components/Organisms/DynamicGallery/_ScrollFix.js +57 -0
  15. package/dist/components/Organisms/DynamicGallery/__snapshots__/DynamicGallery.test.js.snap +1113 -0
  16. package/dist/components/Organisms/DynamicGallery/_types.js +18 -0
  17. package/dist/components/Organisms/DynamicGallery/_utils.js +24 -0
  18. package/dist/index.js +8 -1
  19. package/dist/styleguide/assets/tall.jpg +0 -0
  20. package/dist/styleguide/assets/wide.jpg +0 -0
  21. package/package.json +1 -1
  22. package/playwright/components/organisms/dynamicGallery.spec.js +9 -0
  23. package/src/components/Atoms/Icons/Cross.js +37 -0
  24. package/src/components/Atoms/Picture/Picture.js +4 -1
  25. package/src/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.js +7 -4
  26. package/src/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.md +42 -0
  27. package/src/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.style.js +15 -25
  28. package/src/components/Molecules/CTA/CTAMultiCard/__snapshots__/CTAMultiCard.test.js.snap +12 -12
  29. package/src/components/Organisms/DynamicGallery/DynamicGallery.js +243 -0
  30. package/src/components/Organisms/DynamicGallery/DynamicGallery.md +30 -0
  31. package/src/components/Organisms/DynamicGallery/DynamicGallery.style.js +107 -0
  32. package/src/components/Organisms/DynamicGallery/DynamicGallery.test.js +34 -0
  33. package/src/components/Organisms/DynamicGallery/_DynamicGalleryColumn.js +144 -0
  34. package/src/components/Organisms/DynamicGallery/_Lightbox.js +242 -0
  35. package/src/components/Organisms/DynamicGallery/_Lightbox.style.js +159 -0
  36. package/src/components/Organisms/DynamicGallery/_ScrollFix.js +60 -0
  37. package/src/components/Organisms/DynamicGallery/__snapshots__/DynamicGallery.test.js.snap +1113 -0
  38. package/src/components/Organisms/DynamicGallery/_types.js +12 -0
  39. package/src/components/Organisms/DynamicGallery/_utils.js +28 -0
  40. package/src/index.js +1 -0
  41. package/src/styleguide/assets/tall.jpg +0 -0
  42. package/src/styleguide/assets/wide.jpg +0 -0
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default;
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.GalleryNodeType = void 0;
8
+ var _propTypes = _interopRequireDefault(require("prop-types"));
9
+ // ignoring the linting rule here so we can name the export for clarity;
10
+ // this shared type is used in the DynamicGallery and DynamicGalleryColumn components,
11
+ // so lives here to avoid circular dependencies
12
+ // eslint-disable-next-line import/prefer-default-export
13
+ const GalleryNodeType = exports.GalleryNodeType = _propTypes.default.shape({
14
+ title: _propTypes.default.string,
15
+ image: _propTypes.default.string.isRequired,
16
+ body: _propTypes.default.node,
17
+ caption: _propTypes.default.node
18
+ });
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = createMockGalleryNodes;
7
+ var _data = require("../../../styleguide/data/data");
8
+ /**
9
+ * mocking function to create nodes for the dynamic gallery
10
+ */
11
+ function createMockGalleryNodes() {
12
+ let nodeCount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 10;
13
+ const images = [_data.defaultData.pictures.small, _data.defaultData.pictures.medium, _data.defaultData.pictures.large, _data.defaultData.promoImage, _data.defaultData.heroBannerImage, _data.defaultData.image, 'tall.jpg', 'wide.jpg'];
14
+ const nodes = [];
15
+ for (let i = 0; i < nodeCount; i += 1) {
16
+ nodes.push({
17
+ image: images[Math.floor(Math.random() * images.length)],
18
+ title: `image ${i}`,
19
+ caption: 'Age group: 0-10',
20
+ body: `This is the body for image ${i}. It can be used to display additional information about the image.`
21
+ });
22
+ }
23
+ return nodes;
24
+ }
package/dist/index.js CHANGED
@@ -112,6 +112,12 @@ Object.defineProperty(exports, "DoubleCopy", {
112
112
  return _DoubleCopy.default;
113
113
  }
114
114
  });
115
+ Object.defineProperty(exports, "DynamicGallery", {
116
+ enumerable: true,
117
+ get: function () {
118
+ return _DynamicGallery.default;
119
+ }
120
+ });
115
121
  Object.defineProperty(exports, "ESU_FIELDS", {
116
122
  enumerable: true,
117
123
  get: function () {
@@ -516,4 +522,5 @@ var _Membership = _interopRequireDefault(require("./components/Organisms/Members
516
522
  var _MarketingPreferencesDS = require("./components/Organisms/MarketingPreferencesDS/_MarketingPreferencesDS");
517
523
  var _ImpactSlider = _interopRequireDefault(require("./components/Organisms/ImpactSlider/ImpactSlider"));
518
524
  var _WYMDCarousel = _interopRequireDefault(require("./components/Organisms/WYMDCarousel/WYMDCarousel"));
519
- var _RichtextCarousel = _interopRequireDefault(require("./components/Organisms/RichtextCarousel/RichtextCarousel"));
525
+ var _RichtextCarousel = _interopRequireDefault(require("./components/Organisms/RichtextCarousel/RichtextCarousel"));
526
+ var _DynamicGallery = _interopRequireDefault(require("./components/Organisms/DynamicGallery/DynamicGallery"));
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@comicrelief/component-library",
3
3
  "author": "Comic Relief Engineering Team",
4
- "version": "8.51.7",
4
+ "version": "8.52.0",
5
5
  "main": "dist/index.js",
6
6
  "license": "ISC",
7
7
  "jest": {
@@ -0,0 +1,9 @@
1
+ const { test, expect } = require('@playwright/test');
2
+
3
+ test.describe('dynamic gallery component', () => {
4
+ test('dynamic gallery', async ({ page }) => {
5
+ await page.goto('/#dynamicgallery');
6
+
7
+ await page.close();
8
+ });
9
+ });
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import styled, { withTheme } from 'styled-components';
4
+
5
+ const Icon = styled.svg`
6
+ fill: ${({ colour, theme }) => theme.color(colour)};
7
+ `;
8
+
9
+ const Cross = ({
10
+ colour = 'black',
11
+ mobileColour = null,
12
+ theme,
13
+ size = 24,
14
+ ...rest
15
+ }) => (
16
+ <Icon
17
+ width={size}
18
+ height={size}
19
+ colour={colour}
20
+ mobileColour={mobileColour}
21
+ xmlns="http://www.w3.org/2000/svg"
22
+ viewBox="0 0 96 96"
23
+ {...rest}
24
+ >
25
+ <polygon points="85.48 17.59 78.41 10.52 48 40.93 17.59 10.52 10.52 17.59 40.93 48 10.52 78.41 17.59 85.48 48 55.07 78.41 85.48 85.48 78.41 55.07 48 85.48 17.59" />
26
+ </Icon>
27
+ );
28
+
29
+ Cross.propTypes = {
30
+ colour: PropTypes.string,
31
+ mobileColour: PropTypes.string,
32
+ size: PropTypes.number,
33
+ direction: PropTypes.oneOf(['up', 'down', 'left', 'right']),
34
+ theme: PropTypes.objectOf(PropTypes.shape).isRequired
35
+ };
36
+
37
+ export default withTheme(Cross);
@@ -75,6 +75,7 @@ const Picture = ({
75
75
  isBackgroundImage = false,
76
76
  smallBreakpointRowLayout = null,
77
77
  mediumBreakpointRowLayout = null,
78
+ onLoad,
78
79
  ...rest
79
80
  }) => {
80
81
  const document = typeof window !== 'undefined' ? window.document : null;
@@ -115,6 +116,7 @@ const Picture = ({
115
116
  data-src={image}
116
117
  className="lazyload"
117
118
  objFitState={objFitState}
119
+ onLoad={onLoad}
118
120
  />
119
121
  </Wrapper>
120
122
  );
@@ -169,7 +171,8 @@ Picture.propTypes = {
169
171
  height: PropTypes.string,
170
172
  isBackgroundImage: PropTypes.bool,
171
173
  smallBreakpointRowLayout: PropTypes.bool,
172
- mediumBreakpointRowLayout: PropTypes.bool
174
+ mediumBreakpointRowLayout: PropTypes.bool,
175
+ onLoad: PropTypes.func
173
176
  };
174
177
 
175
178
  export default withTheme(Picture);
@@ -56,9 +56,8 @@ const CTAMultiCard = ({ data }) => {
56
56
  backgroundColor={cardsBackground}
57
57
  paddingAbove={paddingAbove}
58
58
  paddingBelow={paddingBelow}
59
- isCarousel={carouselOfCards}
60
59
  >
61
- <CardsInner isCarousel={carouselOfCards}>
60
+ <CardsInner>
62
61
  <CardsContainer
63
62
  columns={columns}
64
63
  isCarousel={carouselOfCards}
@@ -69,8 +68,12 @@ const CTAMultiCard = ({ data }) => {
69
68
  options={{
70
69
  arrows: false,
71
70
  pagination: false,
72
- drag: true,
73
- dragMinThreshold: 10,
71
+ // Reduce swipe "throw" as Matt felt the defaults are too much
72
+ // See https://splidejs.com/guides/options/
73
+ drag: 'free',
74
+ flickPower: 50,
75
+ perMove: 1,
76
+ dragMinThreshold: { mouse: 50, touch: 50 },
74
77
  gap: '1rem',
75
78
  fixedWidth: '309px',
76
79
  padding: { left: '0px', right: '0px' }
@@ -68,6 +68,48 @@ const data = {
68
68
  </div>;
69
69
  ```
70
70
 
71
+ ### CTAMultiCard: Carousel with just 2 cards (2 columns layout)
72
+
73
+ ```js
74
+ import CTAMultiCard from './CTAMultiCard';
75
+ import Text from '../../../Atoms/Text/Text';
76
+ import challengeExampleImage from '../../../../styleguide/assets/challenge-1.jpg';
77
+ const exampleData = require('./example_data.json');
78
+
79
+ // Map cards to include pre-rendered body content and processed image/link data
80
+ const cardsWithRenderedBody = exampleData.cards.map(card => ({
81
+ ...card,
82
+ body: (
83
+ <Text tag="p">
84
+ {card.body}
85
+ </Text>
86
+ ),
87
+ fallback: challengeExampleImage,
88
+ imageLow: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAPABQDASIAAhEBAxEB/8QAGAAAAwEBAAAAAAAAAAAAAAAAAAcIBAb/xAAjEAACAgIBBAIDAAAAAAAAAAABAgMEABEGBRIhMQdBE1Fh/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgT/xAAaEQADAQADAAAAAAAAAAAAAAAAAQIDERIT/9oADAMBAAIRAxEAPwBzRcrjVY+0tonyT41nG8y+SLFTkgpQTVFpoqiRZGHc2/egf4RrMM12OHpNi3LsrAjO2vsKCcQtvkbTW570sMUt6xphJKnd+Ma9A78ZRWcS+SWNLpNMqAdQidVaSxErEA6ZgCNj9YZNPTOTpJW7+ovdlnLHyjgAD6GGPug+bP/Z",
89
+ images: `${challengeExampleImage} 678w`,
90
+ bgColour: "white",
91
+ description: "",
92
+ target: "self",
93
+ external: null
94
+ }));
95
+
96
+ const cardsTwo = cardsWithRenderedBody.slice(0, 2);
97
+
98
+ const dataTwoCards = {
99
+ ...exampleData,
100
+ cards: cardsTwo,
101
+ layout: "2 columns",
102
+ carouselOfCards: true,
103
+ backgroundColour: "grey_medium",
104
+ paddingAbove: '0rem',
105
+ paddingBelow: '0rem'
106
+ };
107
+
108
+ <div style={{ background: '#E1E2E3', width: '100%' }}>
109
+ <CTAMultiCard data={dataTwoCards} />
110
+ </div>;
111
+ ```
112
+
71
113
  ### CTAMultiCard: Desktop Grid View (2 columns) with large padding
72
114
 
73
115
  **NB: One card contains a lot of lorem ipsum text to demonstrate that all cards will match the height of the tallest sibling card. In mobile view, this example displays as a carousel. This example also demonstrates larger vertical padding via `paddingAbove` / `paddingBelow` set to `4rem`, so it will appear with more space above and below the cards.**
@@ -16,29 +16,19 @@ export const CardsSection = styled.div`
16
16
  background: ${({ theme, backgroundColor }) => theme.color(backgroundColor)};
17
17
  padding-top: ${({ paddingAbove }) => paddingAbove};
18
18
  padding-bottom: ${({ paddingBelow }) => paddingBelow};
19
+ padding-inline: 1rem;
20
+ @media ${({ theme }) => theme.breakpoints2026('M')} {
21
+ padding-inline: 2rem;
22
+ }
23
+ @media ${({ theme }) => theme.breakpoints2026('L')} {
24
+ padding-inline: 4rem;
25
+ }
19
26
  `;
20
27
 
21
28
  export const CardsInner = styled.div`
22
29
  width: 100%;
23
30
  max-width: 1152px;
24
31
  margin: 0 auto;
25
-
26
- ${({ isCarousel }) => !isCarousel && css`
27
- padding-inline: 1rem;
28
- @media ${({ theme }) => theme.allBreakpoints('M')} {
29
- padding-inline: 2rem;
30
- }
31
- `}
32
-
33
- ${({ isCarousel }) => isCarousel && css`
34
- @media ${({ theme }) => theme.allBreakpoints('M')} {
35
- padding-inline: 2rem;
36
- }
37
-
38
- @media (min-width: ${breakpointValues.XL}px) {
39
- padding-inline: 0;
40
- }
41
- `}
42
32
  `;
43
33
 
44
34
  const CardsContainer = styled.div`
@@ -53,13 +43,11 @@ const CardsContainer = styled.div`
53
43
  flex-wrap: wrap;
54
44
  justify-content: center;
55
45
  align-items: stretch;
56
- width: 100%;
57
- max-width: 100%;
58
- margin: 0 auto;
59
46
  }
60
47
 
61
48
  @media ${({ theme }) => theme.allBreakpoints('L')} {
62
49
  column-gap: 2rem;
50
+ row-gap: 2rem;
63
51
  }
64
52
 
65
53
  /* Ensure 2-column layout behaves itself at L+. Applies when Splide is not active. */
@@ -91,21 +79,25 @@ const CardsContainer = styled.div`
91
79
  display: block;
92
80
  cursor: grab;
93
81
  width: 100%;
94
- margin: 0;
95
82
  max-width: 100%;
96
- padding: 0.75rem 1rem;
97
83
  gap: 0;
98
84
 
99
85
  /* We need this so that the box shadows of the cards are not clipped off */
100
86
  .splide,
101
87
  .splide__track {
102
- overflow: visible
88
+ overflow: visible;
103
89
  }
104
90
 
105
91
  .splide__list {
106
92
  align-items: stretch;
107
93
  }
108
94
 
95
+ /* Center slides when there is no overflow (e.g. only 1–2 cards in
96
+ a gap where we'd probably fit 3 or more). */
97
+ .splide:not(.is-overflow) .splide__list {
98
+ justify-content: center;
99
+ }
100
+
109
101
  .splide__slide {
110
102
  display: flex;
111
103
  height: auto;
@@ -115,13 +107,11 @@ const CardsContainer = styled.div`
115
107
  flex-wrap: nowrap;
116
108
  justify-content: flex-start;
117
109
  width: 100%;
118
- margin: 0;
119
110
  max-width: 100%;
120
111
  overflow-x: auto;
121
112
  overflow-y: visible;
122
113
  -webkit-overflow-scrolling: touch;
123
114
  scroll-snap-type: x mandatory;
124
- padding: 0.75rem 1rem;
125
115
 
126
116
  scrollbar-width: none;
127
117
  -ms-overflow-style: none;
@@ -5,13 +5,13 @@ exports[`handles data structure correctly 1`] = `
5
5
  className="CTAMultiCardstyle__CardsQueryWrapper-sc-gsdqzv-0 qqegF"
6
6
  >
7
7
  <div
8
- className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 ehBqzi"
8
+ className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 kCDlNQ"
9
9
  >
10
10
  <div
11
- className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 jdzsUU"
11
+ className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 iFzzWM"
12
12
  >
13
13
  <div
14
- className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 icRtAH"
14
+ className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 gAuYUa"
15
15
  >
16
16
  <div
17
17
  className="CTACardstyle__CardWrapper-sc-si8xx1-5 jZEwGJ"
@@ -288,13 +288,13 @@ exports[`renders 2 columns layout correctly 1`] = `
288
288
  className="CTAMultiCardstyle__CardsQueryWrapper-sc-gsdqzv-0 qqegF"
289
289
  >
290
290
  <div
291
- className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 eznbgX"
291
+ className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 cywbFh"
292
292
  >
293
293
  <div
294
- className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 kjrrbi"
294
+ className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 iFzzWM"
295
295
  >
296
296
  <div
297
- className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 bWeGAp"
297
+ className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 jrSYsC"
298
298
  >
299
299
  <div
300
300
  className="CTACardstyle__CardWrapper-sc-si8xx1-5 xdzCi"
@@ -571,13 +571,13 @@ exports[`renders carousel mode correctly 1`] = `
571
571
  className="CTAMultiCardstyle__CardsQueryWrapper-sc-gsdqzv-0 qqegF"
572
572
  >
573
573
  <div
574
- className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 eznbgX"
574
+ className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 cywbFh"
575
575
  >
576
576
  <div
577
- className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 kjrrbi"
577
+ className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 iFzzWM"
578
578
  >
579
579
  <div
580
- className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 eomXiE"
580
+ className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 fqrDP"
581
581
  >
582
582
  <div
583
583
  className="CTACardstyle__CardWrapper-sc-si8xx1-5 eieQSs"
@@ -854,13 +854,13 @@ exports[`renders correctly with data prop 1`] = `
854
854
  className="CTAMultiCardstyle__CardsQueryWrapper-sc-gsdqzv-0 qqegF"
855
855
  >
856
856
  <div
857
- className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 eznbgX"
857
+ className="CTAMultiCardstyle__CardsSection-sc-gsdqzv-1 cywbFh"
858
858
  >
859
859
  <div
860
- className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 kjrrbi"
860
+ className="CTAMultiCardstyle__CardsInner-sc-gsdqzv-2 iFzzWM"
861
861
  >
862
862
  <div
863
- className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 eomXiE"
863
+ className="CTAMultiCardstyle__CardsContainer-sc-gsdqzv-3 fqrDP"
864
864
  >
865
865
  <div
866
866
  className="CTACardstyle__CardWrapper-sc-si8xx1-5 eieQSs"
@@ -0,0 +1,243 @@
1
+ import { floor, orderBy, throttle } from 'lodash';
2
+ import PropTypes from 'prop-types';
3
+ import React, {
4
+ useEffect,
5
+ useRef,
6
+ useState
7
+ } from 'react';
8
+ import { useMediaQuery } from 'react-responsive';
9
+ import { breakpointValues2026 as breakpointValues } from '../../../theme/shared/breakpoints2026';
10
+ import Button from '../../Atoms/Button/Button';
11
+ import Lightbox, { LightboxContext } from './_Lightbox';
12
+ import {
13
+ Container,
14
+ EmptyMessage,
15
+ ImageGrid
16
+ } from './DynamicGallery.style';
17
+ import DynamicGalleryColumn from './_DynamicGalleryColumn';
18
+ import { GalleryNodeType } from './_types';
19
+
20
+ /**
21
+ * the Dynamic Gallery component displays a grid of images,
22
+ * by default using dynamic heights per image to create an more organic look
23
+ */
24
+ const DynamicGallery = ({
25
+ pageBackgroundColour = 'transparent',
26
+ textColour = 'black',
27
+ gridWidth = 3,
28
+ maxWidth = '1500px',
29
+ loadingBehaviour = '25',
30
+ imageRatio = 'dynamic',
31
+ useLightbox = true,
32
+ nodes = [],
33
+ paddingTop = '0rem',
34
+ paddingBottom = '2rem'
35
+ }) => {
36
+ const hasNodes = nodes?.length > 0;
37
+ const containerRef = useRef(null);
38
+
39
+ // handle loading behaviour;
40
+ // if we're in chunk mode, display images a chunk at a time
41
+ // (or the total number of images if less than the chunk size)
42
+ // or display all images at once
43
+ const isChunked = loadingBehaviour !== 'all';
44
+ const imageChunkSize = +loadingBehaviour;
45
+ const [imageCount, setImageCount] = useState(isChunked
46
+ ? Math.min(imageChunkSize, nodes.length) : nodes.length);
47
+
48
+ function handleLoadMore() {
49
+ setImageCount(imageCount + imageChunkSize);
50
+ }
51
+
52
+ // assign a manual tabbing order to gallery images based on their position in the DOM,
53
+ // starting from top-left and working downwards in a natural order
54
+ function updateTabOrder() {
55
+ if (!containerRef.current) return;
56
+ const galleryNodes = containerRef.current.querySelectorAll('.gallery-node');
57
+ const sortedNodes = orderBy(galleryNodes, node => {
58
+ const { top, left } = node.getBoundingClientRect();
59
+ return floor(top, -2) + Math.floor(left) / 1000;
60
+ }, 'asc');
61
+ sortedNodes.forEach((galleryNode, index) => {
62
+ galleryNode.setAttribute('data-order', String(index));
63
+ });
64
+ }
65
+ // create a throttled version of the updateTabOrder function
66
+ const throttledUpdateTabOrder = useRef(throttle(updateTabOrder, 2000));
67
+
68
+ /**
69
+ * handle column counts;
70
+ * column count is based on a combination of the maxColumns prop and the window width
71
+ * - for small screens columns = 1
72
+ * - for medium screens columns = 2
73
+ * - for large and xl screens we use the maxColumns prop which defaults to 3
74
+ * .
75
+ * we need to use JS here rather than CSS because our columns are created dynamically;
76
+ * this is to allow us to assign nodes in the natural "horizontal" order rather than "vertically"
77
+ */
78
+ const [columnCount, setColumnCount] = useState(gridWidth);
79
+ const isSmall = useMediaQuery({ maxWidth: breakpointValues.S });
80
+ const isMedium = useMediaQuery({ maxWidth: breakpointValues.M });
81
+
82
+ useEffect(() => {
83
+ let newColumnCount;
84
+ switch (true) {
85
+ case isSmall:
86
+ newColumnCount = 1;
87
+ break;
88
+ case isMedium:
89
+ newColumnCount = 2;
90
+ break;
91
+ default:
92
+ newColumnCount = gridWidth;
93
+ break;
94
+ }
95
+ setColumnCount(newColumnCount);
96
+ throttledUpdateTabOrder.current();
97
+ }, [isSmall, isMedium, gridWidth, setColumnCount]);
98
+
99
+ // handle selected gallery node
100
+ const [selectedNode, setSelectedNode] = useState(null);
101
+
102
+ // handle next/previous node events from the lightbox
103
+ function handleNextNode(node) {
104
+ const nodeIndex = nodes.indexOf(node);
105
+ const nextNodeIndex = (nodeIndex + 1) % imageCount;
106
+ setSelectedNode(nodes[nextNodeIndex]);
107
+ }
108
+ function handlePreviousNode(node) {
109
+ const nodeIndex = nodes.indexOf(node);
110
+ const previousNodeIndex = (nodeIndex - 1 + imageCount) % imageCount;
111
+ setSelectedNode(nodes[previousNodeIndex]);
112
+ }
113
+
114
+ // handle keydown events,
115
+ // including image opening and tabbing
116
+ function handleKeyDown(event) {
117
+ switch (event.key) {
118
+ // if the lightbox is enabled, open the image in the lightbox when the user presses enter
119
+ case 'Enter': {
120
+ if (useLightbox) {
121
+ event.preventDefault();
122
+ const nodeIndex = +event.target.dataset.nodeIndex;
123
+ if (Number.isNaN(nodeIndex)) return;
124
+ setSelectedNode(nodes[nodeIndex]);
125
+ }
126
+ break;
127
+ }
128
+ // handle tabbing between images;
129
+ // there doesn't seem to be a great way to handle this!
130
+ // it's tied into the way the grid is structured, and the way the nodes are rendered;
131
+ // ideal scenario would be a tabbable grid with nice ordering and no gaps,
132
+ // but this isn't currently possible without either getting a bit hacky with CSS or JS
133
+ // our options are:
134
+ // - use a standard CSS grid > ordered and tabble but gappy
135
+ // - use absolute positioning > no gaps but complex and weird DOM order (pinterest approach)
136
+ // - flex-column+order > no gaps but complex (https://mui.com/material-ui/react-masonry/)
137
+ // - columns + custom tabbing > what we're doing here
138
+ case 'Tab': {
139
+ const nodeIndex = +event.target.dataset.order;
140
+ if (Number.isNaN(nodeIndex)) return;
141
+ const galleryContainer = event.target.closest('.gallery-container');
142
+ if (!galleryContainer) return;
143
+
144
+ let newNodeIndex;
145
+
146
+ if (event.shiftKey) {
147
+ // shift-tab: move to the previous image
148
+ newNodeIndex = nodeIndex - 1;
149
+ if (newNodeIndex < 0) return;
150
+ event.preventDefault();
151
+ galleryContainer.querySelector(`[data-order="${newNodeIndex}"]`).focus();
152
+ } else {
153
+ // tab: move to the next image
154
+ newNodeIndex = nodeIndex + 1;
155
+ if (newNodeIndex >= imageCount) {
156
+ // if we're on the last image, move to the focus trap
157
+ // before allowing the tab event to continue to the next natural element;
158
+ // this is a bit hacky but is needed for when the "last" image isn't in the last column;
159
+ // eg 10 images divided across 3 columns = [4, 3, 3]
160
+ // when this happens the browser tries to tab into the next column,
161
+ // rather than out of the grid and onwards
162
+ galleryContainer.querySelector('.gallery-focus-trap').focus();
163
+ return;
164
+ }
165
+ event.preventDefault();
166
+ galleryContainer.querySelector(`[data-order="${newNodeIndex}"]`).focus();
167
+ }
168
+ break;
169
+ }
170
+ default:
171
+ break;
172
+ }
173
+ }
174
+
175
+ return (
176
+ <Container
177
+ className="gallery-container"
178
+ ref={containerRef}
179
+ maxWidth={maxWidth}
180
+ pageBackgroundColour={pageBackgroundColour}
181
+ textColour={textColour}
182
+ paddingTop={paddingTop}
183
+ paddingBottom={paddingBottom}
184
+ >
185
+ <LightboxContext.Provider
186
+ value={{
187
+ useLightbox,
188
+ selectedNode,
189
+ setSelectedNode,
190
+ nextNode: handleNextNode,
191
+ previousNode: handlePreviousNode
192
+ }}
193
+ >
194
+ <ImageGrid className="gallery-grid" onKeyDown={event => handleKeyDown(event)}>
195
+ {hasNodes
196
+ && Array(columnCount)
197
+ .fill(null)
198
+ .map((column, columnIndex) => (
199
+ <DynamicGalleryColumn
200
+ // disabling the lint rule here
201
+ // as we're chunking an array and have no unique keys
202
+ // eslint-disable-next-line react/no-array-index-key
203
+ key={columnIndex}
204
+ columnIndex={columnIndex}
205
+ columnCount={columnCount}
206
+ nodes={nodes.slice(0, imageCount)}
207
+ imageRatio={imageRatio}
208
+ updateTabOrder={throttledUpdateTabOrder.current}
209
+ />
210
+ ))}
211
+
212
+ <EmptyMessage isEmpty={!hasNodes}>No images to display</EmptyMessage>
213
+ </ImageGrid>
214
+ <Lightbox />
215
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
216
+ <div className="gallery-focus-trap" tabIndex={0} />
217
+ </LightboxContext.Provider>
218
+ {imageCount < nodes.length && <Button onClick={() => handleLoadMore()}>Load more</Button>}
219
+ </Container>
220
+ );
221
+ };
222
+
223
+ DynamicGallery.propTypes = {
224
+ // title: PropTypes.string,
225
+ pageBackgroundColour: PropTypes.string,
226
+ textColour: PropTypes.string,
227
+ gridWidth: PropTypes.oneOf([2, 3, 4, 5]),
228
+ maxWidth: PropTypes.string,
229
+ loadingBehaviour: PropTypes.oneOf([
230
+ 'all',
231
+ '25'
232
+ ]),
233
+ imageRatio: PropTypes.oneOf([
234
+ 'dynamic',
235
+ '4:3'
236
+ ]),
237
+ useLightbox: PropTypes.bool,
238
+ nodes: PropTypes.arrayOf(GalleryNodeType),
239
+ paddingTop: PropTypes.string,
240
+ paddingBottom: PropTypes.string
241
+ };
242
+
243
+ export default DynamicGallery;
@@ -0,0 +1,30 @@
1
+ # Dynamic Gallery
2
+
3
+ ### Empty gallery
4
+
5
+ ```js
6
+ <DynamicGallery />
7
+ ```
8
+
9
+ ### Basic gallery
10
+
11
+ ```js
12
+ const defaultData = require('../../../styleguide/data/data').defaultData;
13
+ import createMockGalleryNodes from './_utils';
14
+ <DynamicGallery nodes={createMockGalleryNodes(50)} />;
15
+ ```
16
+
17
+ ### Customised gallery with multiple options
18
+ ```js
19
+ const defaultData = require('../../../styleguide/data/data').defaultData;
20
+ import createMockGalleryNodes from './_utils';
21
+ <DynamicGallery gridWidth={4} nodes={createMockGalleryNodes(4)} loadingBehaviour="all" imageRatio="4:3" pageBackgroundColour="blue" textColour="white" paddingTop="6rem" paddingBottom="6rem" useLightbox={false} />;
22
+ ```
23
+
24
+ ### Gallery with max 5 columns
25
+
26
+ ```js
27
+ const defaultData = require('../../../styleguide/data/data').defaultData;
28
+ import createMockGalleryNodes from './_utils';
29
+ <DynamicGallery gridWidth={5} nodes={createMockGalleryNodes(5)} />;
30
+ ```