@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.
- package/dist/components/Atoms/Icons/Cross.js +40 -0
- package/dist/components/Atoms/Picture/Picture.js +3 -1
- package/dist/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.js +11 -7
- package/dist/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.md +42 -0
- package/dist/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.style.js +23 -33
- package/dist/components/Molecules/CTA/CTAMultiCard/__snapshots__/CTAMultiCard.test.js.snap +12 -12
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.js +218 -0
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.md +30 -0
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.style.js +97 -0
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.test.js +33 -0
- package/dist/components/Organisms/DynamicGallery/_DynamicGalleryColumn.js +111 -0
- package/dist/components/Organisms/DynamicGallery/_Lightbox.js +218 -0
- package/dist/components/Organisms/DynamicGallery/_Lightbox.style.js +86 -0
- package/dist/components/Organisms/DynamicGallery/_ScrollFix.js +57 -0
- package/dist/components/Organisms/DynamicGallery/__snapshots__/DynamicGallery.test.js.snap +1113 -0
- package/dist/components/Organisms/DynamicGallery/_types.js +18 -0
- package/dist/components/Organisms/DynamicGallery/_utils.js +24 -0
- package/dist/index.js +8 -1
- package/dist/styleguide/assets/tall.jpg +0 -0
- package/dist/styleguide/assets/wide.jpg +0 -0
- package/package.json +1 -1
- package/playwright/components/organisms/dynamicGallery.spec.js +9 -0
- package/src/components/Atoms/Icons/Cross.js +37 -0
- package/src/components/Atoms/Picture/Picture.js +4 -1
- package/src/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.js +7 -4
- package/src/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.md +42 -0
- package/src/components/Molecules/CTA/CTAMultiCard/CTAMultiCard.style.js +15 -25
- package/src/components/Molecules/CTA/CTAMultiCard/__snapshots__/CTAMultiCard.test.js.snap +12 -12
- package/src/components/Organisms/DynamicGallery/DynamicGallery.js +243 -0
- package/src/components/Organisms/DynamicGallery/DynamicGallery.md +30 -0
- package/src/components/Organisms/DynamicGallery/DynamicGallery.style.js +107 -0
- package/src/components/Organisms/DynamicGallery/DynamicGallery.test.js +34 -0
- package/src/components/Organisms/DynamicGallery/_DynamicGalleryColumn.js +144 -0
- package/src/components/Organisms/DynamicGallery/_Lightbox.js +242 -0
- package/src/components/Organisms/DynamicGallery/_Lightbox.style.js +159 -0
- package/src/components/Organisms/DynamicGallery/_ScrollFix.js +60 -0
- package/src/components/Organisms/DynamicGallery/__snapshots__/DynamicGallery.test.js.snap +1113 -0
- package/src/components/Organisms/DynamicGallery/_types.js +12 -0
- package/src/components/Organisms/DynamicGallery/_utils.js +28 -0
- package/src/index.js +1 -0
- package/src/styleguide/assets/tall.jpg +0 -0
- package/src/styleguide/assets/wide.jpg +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import styled, { css } from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
align-items: center;
|
|
7
|
+
gap: 1rem;
|
|
8
|
+
max-width: ${({ maxWidth }) => maxWidth};
|
|
9
|
+
background: ${({ theme, pageBackgroundColour }) => theme.color(pageBackgroundColour)};
|
|
10
|
+
${({ paddingTop, paddingBottom }) => css`padding: ${paddingTop} 2rem ${paddingBottom};`}
|
|
11
|
+
color: ${({ theme, textColour }) => theme.color(textColour)};
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
export const ImageGrid = styled.div`
|
|
15
|
+
display: flex;
|
|
16
|
+
gap: 1rem;
|
|
17
|
+
width: 100%;
|
|
18
|
+
|
|
19
|
+
@media ${({ theme }) => theme.breakpoints2026('M')} {
|
|
20
|
+
gap: 2rem;
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export const Column = styled.div`
|
|
25
|
+
flex: 1;
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
gap: 1.1rem;
|
|
29
|
+
|
|
30
|
+
@media ${({ theme }) => theme.breakpoints2026('M')} {
|
|
31
|
+
gap: 2rem;
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export const EmptyMessage = styled.div`
|
|
36
|
+
display: ${({ isEmpty }) => (isEmpty ? 'block' : 'none')};
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const GalleryNodeBase = css`
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: 0.8rem;
|
|
43
|
+
padding: 0;
|
|
44
|
+
margin: 0;
|
|
45
|
+
background: none;
|
|
46
|
+
border: none;
|
|
47
|
+
text-align: left;
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
export const GalleryNode = styled.div`
|
|
51
|
+
${GalleryNodeBase}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export const InteractiveGalleryNode = styled.button`
|
|
55
|
+
${GalleryNodeBase}
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
color: inherit;
|
|
58
|
+
|
|
59
|
+
& div:first-child {
|
|
60
|
+
transition: all 0.1s ease-out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&:focus-visible {
|
|
64
|
+
outline: 2px solid #000000;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
& > div:first-child {
|
|
68
|
+
&:hover {
|
|
69
|
+
box-shadow: 0px 3px 10px 0px rgba(0, 0, 0, 0.4);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
export const ImageContainer = styled.div`
|
|
75
|
+
display: flex;
|
|
76
|
+
height: auto;
|
|
77
|
+
width: 100%;
|
|
78
|
+
min-height: ${({ minHeight }) => minHeight};
|
|
79
|
+
max-height: ${({ maxHeight }) => maxHeight};
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
border-radius: 1rem;
|
|
82
|
+
background: rgba(0, 0, 0, 0.05);
|
|
83
|
+
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.2);
|
|
84
|
+
|
|
85
|
+
img {
|
|
86
|
+
height: 100%;
|
|
87
|
+
opacity: 0;
|
|
88
|
+
transition: opacity 0.1s ease-out 0.3s;
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
export const Details = styled.div`
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
gap: 0.5rem;
|
|
96
|
+
padding: 0 1rem;
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
export const Title = styled.div`
|
|
100
|
+
&:first-child {
|
|
101
|
+
margin-bottom: 0;
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
export const Caption = styled.div`
|
|
106
|
+
line-height: 1;
|
|
107
|
+
`;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import 'jest-styled-components';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import renderWithTheme from '../../../../tests/hoc/shallowWithTheme';
|
|
4
|
+
import DynamicGallery from './DynamicGallery';
|
|
5
|
+
|
|
6
|
+
it('renders an empty Dynamic Gallery with no options set', () => {
|
|
7
|
+
const galleryEl = renderWithTheme(<DynamicGallery />).toJSON();
|
|
8
|
+
expect(galleryEl).toMatchSnapshot();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('handle a Dynamic Gallery with mocked gallery nodes', () => {
|
|
12
|
+
const nodes = [
|
|
13
|
+
{
|
|
14
|
+
image: 'image1.jpg',
|
|
15
|
+
title: 'Image 1',
|
|
16
|
+
caption: 'Caption 1',
|
|
17
|
+
body: 'Body 1'
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
image: 'image2.jpg',
|
|
21
|
+
title: 'Image 2',
|
|
22
|
+
caption: 'Caption 2',
|
|
23
|
+
body: 'Body 2'
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
image: 'image3.jpg',
|
|
27
|
+
title: 'Image 3',
|
|
28
|
+
caption: 'Caption 3',
|
|
29
|
+
body: 'Body 3'
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
const galleryEl = renderWithTheme(<DynamicGallery nodes={nodes} />).toJSON();
|
|
33
|
+
expect(galleryEl).toMatchSnapshot();
|
|
34
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { throttle } from 'lodash';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import React, {
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from 'react';
|
|
10
|
+
import Picture from '../../Atoms/Picture/Picture';
|
|
11
|
+
import { LightboxContext } from './_Lightbox';
|
|
12
|
+
import {
|
|
13
|
+
Caption,
|
|
14
|
+
Column,
|
|
15
|
+
Details,
|
|
16
|
+
GalleryNode,
|
|
17
|
+
ImageContainer,
|
|
18
|
+
InteractiveGalleryNode,
|
|
19
|
+
Title
|
|
20
|
+
} from './DynamicGallery.style';
|
|
21
|
+
import { GalleryNodeType } from './_types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* a separate component to handle columns of images;
|
|
25
|
+
* this component handles aspect ratio calculations to enfore a min/max ratio for its images
|
|
26
|
+
*/
|
|
27
|
+
export default function DynamicGalleryColumn({
|
|
28
|
+
updateTabOrder,
|
|
29
|
+
nodes,
|
|
30
|
+
imageRatio,
|
|
31
|
+
columnIndex,
|
|
32
|
+
columnCount
|
|
33
|
+
}) {
|
|
34
|
+
const [minHeight, setMinHeight] = useState();
|
|
35
|
+
const [maxHeight, setMaxHeight] = useState();
|
|
36
|
+
const elRef = useRef(null);
|
|
37
|
+
|
|
38
|
+
const updateMinMaxHeight = useCallback(() => {
|
|
39
|
+
if (!elRef.current) return;
|
|
40
|
+
|
|
41
|
+
let minAspectRatio;
|
|
42
|
+
let maxAspectRatio;
|
|
43
|
+
|
|
44
|
+
// handle aspect ratio;
|
|
45
|
+
// for dynamic aspect ratio, we use a min/max ratio of 2.35:1 and 9:16
|
|
46
|
+
// but if a specific aspect ratio is provided, use that instead
|
|
47
|
+
switch (imageRatio) {
|
|
48
|
+
case '4:3':
|
|
49
|
+
minAspectRatio = 4 / 3;
|
|
50
|
+
maxAspectRatio = 4 / 3;
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
minAspectRatio = 2.35 / 1;
|
|
54
|
+
maxAspectRatio = 9 / 16;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const columnWidth = elRef.current.clientWidth;
|
|
59
|
+
setMinHeight(columnWidth / minAspectRatio);
|
|
60
|
+
setMaxHeight(columnWidth / maxAspectRatio);
|
|
61
|
+
}, [imageRatio, setMinHeight, setMaxHeight]);
|
|
62
|
+
|
|
63
|
+
// call repeatedly on column resize
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
// when the column width changes, recalculate the min/max height for images
|
|
66
|
+
const handleResize = throttle(() => {
|
|
67
|
+
updateMinMaxHeight();
|
|
68
|
+
}, 500);
|
|
69
|
+
|
|
70
|
+
const resizeObserver = new ResizeObserver(handleResize);
|
|
71
|
+
resizeObserver.observe(elRef.current);
|
|
72
|
+
|
|
73
|
+
// call once on initial mount
|
|
74
|
+
updateMinMaxHeight();
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
resizeObserver.disconnect();
|
|
78
|
+
};
|
|
79
|
+
}, [updateMinMaxHeight]);
|
|
80
|
+
|
|
81
|
+
const { useLightbox, setSelectedNode } = useContext(LightboxContext);
|
|
82
|
+
|
|
83
|
+
// on click, open the image in the lightbox;
|
|
84
|
+
// conditionally enabled depending on the gallery settings
|
|
85
|
+
function handlePointerUp(node) {
|
|
86
|
+
setSelectedNode(node);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NodeComponent = useLightbox ? InteractiveGalleryNode : GalleryNode;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Column ref={elRef} className="gallery-column">
|
|
93
|
+
{nodes
|
|
94
|
+
?.filter((_, nodeIndex) => nodeIndex % columnCount === columnIndex)
|
|
95
|
+
.map((node, nodeIndex) => (
|
|
96
|
+
<NodeComponent
|
|
97
|
+
key={String(nodeIndex) + node.title}
|
|
98
|
+
className="gallery-node"
|
|
99
|
+
title={node.title}
|
|
100
|
+
aria-label={node.title}
|
|
101
|
+
data-node-index={nodeIndex}
|
|
102
|
+
onPointerUp={useLightbox ? () => handlePointerUp(node) : undefined}
|
|
103
|
+
tabIndex={0}
|
|
104
|
+
>
|
|
105
|
+
<ImageContainer
|
|
106
|
+
className="gallery-node-image"
|
|
107
|
+
// eslint-disable-next-line prefer-template
|
|
108
|
+
minHeight={String(minHeight) + 'px'}
|
|
109
|
+
// eslint-disable-next-line prefer-template
|
|
110
|
+
maxHeight={String(maxHeight) + 'px'}
|
|
111
|
+
>
|
|
112
|
+
<Picture
|
|
113
|
+
image={node.image}
|
|
114
|
+
objectFit="cover"
|
|
115
|
+
alt={node.title}
|
|
116
|
+
// animate image in on load
|
|
117
|
+
onLoad={event => {
|
|
118
|
+
event.target
|
|
119
|
+
.closest('.gallery-node-image')
|
|
120
|
+
.querySelector('img')
|
|
121
|
+
.style.setProperty('opacity', '1');
|
|
122
|
+
|
|
123
|
+
// update tab order once the image has loaded
|
|
124
|
+
updateTabOrder();
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
</ImageContainer>
|
|
128
|
+
<Details>
|
|
129
|
+
<Title>{node.title}</Title>
|
|
130
|
+
{node.caption && <Caption>{node.caption}</Caption>}
|
|
131
|
+
</Details>
|
|
132
|
+
</NodeComponent>
|
|
133
|
+
))}
|
|
134
|
+
</Column>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
DynamicGalleryColumn.propTypes = {
|
|
139
|
+
nodes: PropTypes.arrayOf(GalleryNodeType),
|
|
140
|
+
imageRatio: PropTypes.oneOf(['dynamic', '4:3']),
|
|
141
|
+
columnIndex: PropTypes.number,
|
|
142
|
+
columnCount: PropTypes.number,
|
|
143
|
+
updateTabOrder: PropTypes.func
|
|
144
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useContext, useEffect, useRef, useState
|
|
3
|
+
} from 'react';
|
|
4
|
+
import PulseLoader from 'react-spinners/PulseLoader';
|
|
5
|
+
import Arrow from '../../Atoms/Icons/Arrow';
|
|
6
|
+
import Cross from '../../Atoms/Icons/Cross';
|
|
7
|
+
import Picture from '../../Atoms/Picture/Picture';
|
|
8
|
+
import {
|
|
9
|
+
Backdrop,
|
|
10
|
+
CloseButton,
|
|
11
|
+
Container,
|
|
12
|
+
Dialog,
|
|
13
|
+
LightboxContent,
|
|
14
|
+
LightboxDetails,
|
|
15
|
+
LightboxImage,
|
|
16
|
+
LightboxSpinner,
|
|
17
|
+
NextButton,
|
|
18
|
+
PreviousButton,
|
|
19
|
+
ScreenReaderOnly
|
|
20
|
+
} from './_Lightbox.style';
|
|
21
|
+
import ScrollFix from './_ScrollFix';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* lightbox context:
|
|
25
|
+
* - selectedNode: the node that is currently selected
|
|
26
|
+
* - setSelectedNode: set the selected node
|
|
27
|
+
* - nextNode/previousNode: navigate to the next/previous node
|
|
28
|
+
*/
|
|
29
|
+
export const LightboxContext = React.createContext(null);
|
|
30
|
+
|
|
31
|
+
// get all focusable elements within the dialog
|
|
32
|
+
function getFocusableElements(element) {
|
|
33
|
+
if (!(element instanceof Element)) return [];
|
|
34
|
+
|
|
35
|
+
const focusableSelectors = [
|
|
36
|
+
'button:not([disabled])',
|
|
37
|
+
'[href]',
|
|
38
|
+
'input:not([disabled])',
|
|
39
|
+
'select:not([disabled])',
|
|
40
|
+
'textarea:not([disabled])',
|
|
41
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
42
|
+
].join(', ');
|
|
43
|
+
return Array.from(element.querySelectorAll(focusableSelectors));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* the Lightbox component is a modal that displays a single image,
|
|
48
|
+
* along with UI to navigate through the gallery
|
|
49
|
+
* .
|
|
50
|
+
* accessibility features like tabbing and focus management are currently implemented here;
|
|
51
|
+
* a better long-term approach would be to use an established modal/dialog library,
|
|
52
|
+
* but to avoid friction with the build process we've gone custom for now
|
|
53
|
+
* .
|
|
54
|
+
* TODO: hide the main window scroll bar in a nicer way, see:
|
|
55
|
+
* https://www.npmjs.com/package/react-remove-scroll
|
|
56
|
+
*/
|
|
57
|
+
const Lightbox = () => {
|
|
58
|
+
const {
|
|
59
|
+
selectedNode,
|
|
60
|
+
setSelectedNode,
|
|
61
|
+
nextNode,
|
|
62
|
+
previousNode
|
|
63
|
+
} = useContext(LightboxContext);
|
|
64
|
+
|
|
65
|
+
const hasNode = Boolean(selectedNode);
|
|
66
|
+
const dialogRef = useRef(null);
|
|
67
|
+
const previousFocusRef = useRef(null);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* handle keyboard events within the lightbox;
|
|
71
|
+
* - trapped focus between UI elements
|
|
72
|
+
* - navigation between images
|
|
73
|
+
* - closing the lightbox
|
|
74
|
+
*/
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
// trap focus within the dialog
|
|
77
|
+
function handleTabKey(event) {
|
|
78
|
+
if (!hasNode) return;
|
|
79
|
+
|
|
80
|
+
const focusableElements = getFocusableElements(dialogRef.current);
|
|
81
|
+
if (focusableElements.length === 0) return;
|
|
82
|
+
|
|
83
|
+
const firstElement = focusableElements[0];
|
|
84
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
85
|
+
const currentElement = document.activeElement;
|
|
86
|
+
|
|
87
|
+
// if shift+tab is pressed and we're on the first element, move to the last
|
|
88
|
+
if (event.shiftKey && currentElement === firstElement) {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
lastElement.focus();
|
|
91
|
+
} else if (!event.shiftKey && currentElement === lastElement) {
|
|
92
|
+
// if tab is pressed and we're on the last element, move to the first
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
firstElement.focus();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleKeyDown(event) {
|
|
99
|
+
switch (event.key) {
|
|
100
|
+
case 'Escape':
|
|
101
|
+
setSelectedNode(null);
|
|
102
|
+
break;
|
|
103
|
+
case 'Tab':
|
|
104
|
+
handleTabKey(event);
|
|
105
|
+
break;
|
|
106
|
+
case 'ArrowLeft':
|
|
107
|
+
previousNode(selectedNode);
|
|
108
|
+
break;
|
|
109
|
+
case 'ArrowRight':
|
|
110
|
+
nextNode(selectedNode);
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (hasNode) {
|
|
118
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
123
|
+
};
|
|
124
|
+
}, [hasNode, selectedNode, setSelectedNode, previousNode, nextNode]);
|
|
125
|
+
|
|
126
|
+
// handle focus management when dialog opens/closes
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
// when the lightbox is opened, store the previously focused element
|
|
129
|
+
// and move focus to the first focusable element in the dialog
|
|
130
|
+
if (hasNode) {
|
|
131
|
+
// store the previously focused element
|
|
132
|
+
previousFocusRef.current = document.activeElement;
|
|
133
|
+
// move focus to the first focusable element in the dialog
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
const focusableElements = getFocusableElements(dialogRef.current);
|
|
136
|
+
if (focusableElements.length > 0) {
|
|
137
|
+
focusableElements[0].focus();
|
|
138
|
+
}
|
|
139
|
+
}, 0);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// when the lightbox is closed, restore focus to the previously focused element
|
|
144
|
+
if (
|
|
145
|
+
previousFocusRef.current
|
|
146
|
+
&& typeof previousFocusRef.current.focus === 'function'
|
|
147
|
+
) {
|
|
148
|
+
previousFocusRef.current.focus();
|
|
149
|
+
previousFocusRef.current = null;
|
|
150
|
+
}
|
|
151
|
+
}, [hasNode]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* close the lightbox when the backdrop is clicked
|
|
155
|
+
*/
|
|
156
|
+
function handleBackdropClick() {
|
|
157
|
+
setSelectedNode(null);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// handle transitions between images nicely;
|
|
161
|
+
const [imageDimensions, setImageDimensions] = useState({ width: '0px', height: '0px' });
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* when the image loads, check to see how best we can fit it on screen,
|
|
165
|
+
* then set width and height on the element;
|
|
166
|
+
* this lets us transition nicely to the new size
|
|
167
|
+
*/
|
|
168
|
+
function onLoad(event) {
|
|
169
|
+
const { target } = event;
|
|
170
|
+
const imageWidth = target.naturalWidth;
|
|
171
|
+
const imageHeight = target.naturalHeight;
|
|
172
|
+
const maxWidth = Math.min.apply(null, [imageWidth, 1024, window.innerWidth * 0.85]);
|
|
173
|
+
const maxHeight = Math.min.apply(null, [imageHeight, 1024, window.innerHeight * 0.5]);
|
|
174
|
+
const scaleX = maxWidth / imageWidth;
|
|
175
|
+
const scaleY = maxHeight / imageHeight;
|
|
176
|
+
const scale = Math.min(scaleX, scaleY);
|
|
177
|
+
const width = imageWidth * scale;
|
|
178
|
+
const height = imageHeight * scale;
|
|
179
|
+
|
|
180
|
+
// set the width and height on the image element, and make it visible
|
|
181
|
+
setImageDimensions({ width: `${width}px`, height: `${height}px` });
|
|
182
|
+
target.style.opacity = '1';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<Container isOpen={hasNode}>
|
|
187
|
+
<Backdrop onPointerUp={() => handleBackdropClick()} />
|
|
188
|
+
<Dialog
|
|
189
|
+
ref={dialogRef}
|
|
190
|
+
aria-labelledby="lightboxTitle"
|
|
191
|
+
aria-describedby="lightboxDescription"
|
|
192
|
+
>
|
|
193
|
+
{hasNode && <ScrollFix />}
|
|
194
|
+
<LightboxContent>
|
|
195
|
+
<LightboxImage className="lightbox-image">
|
|
196
|
+
<LightboxSpinner>
|
|
197
|
+
<PulseLoader height={16} width={2} color="#E1E2E3" />
|
|
198
|
+
</LightboxSpinner>
|
|
199
|
+
{hasNode && (
|
|
200
|
+
<Picture
|
|
201
|
+
key={selectedNode?.image}
|
|
202
|
+
alt={selectedNode?.title}
|
|
203
|
+
image={selectedNode?.image}
|
|
204
|
+
width={imageDimensions.width}
|
|
205
|
+
height={imageDimensions.height}
|
|
206
|
+
objectFit="contain"
|
|
207
|
+
onLoad={event => onLoad(event)}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
</LightboxImage>
|
|
211
|
+
<LightboxDetails id="lightboxDescription" aria-live="polite" aria-atomic="true">
|
|
212
|
+
<div id="lightboxTitle">{selectedNode?.title}</div>
|
|
213
|
+
{selectedNode?.caption && (
|
|
214
|
+
<div>
|
|
215
|
+
{selectedNode?.caption}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
{selectedNode?.body && (
|
|
219
|
+
<div>
|
|
220
|
+
{selectedNode.body}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</LightboxDetails>
|
|
224
|
+
<CloseButton type="button" onClick={() => setSelectedNode(null)}>
|
|
225
|
+
<ScreenReaderOnly>Close</ScreenReaderOnly>
|
|
226
|
+
<Cross colour="black" size={16} />
|
|
227
|
+
</CloseButton>
|
|
228
|
+
<PreviousButton type="button" onClick={() => previousNode(selectedNode)}>
|
|
229
|
+
<ScreenReaderOnly>Previous</ScreenReaderOnly>
|
|
230
|
+
<Arrow direction="left" colour="black" size={16} />
|
|
231
|
+
</PreviousButton>
|
|
232
|
+
<NextButton type="button" onClick={() => nextNode(selectedNode)}>
|
|
233
|
+
<ScreenReaderOnly>Next</ScreenReaderOnly>
|
|
234
|
+
<Arrow direction="right" colour="black" size={16} />
|
|
235
|
+
</NextButton>
|
|
236
|
+
</LightboxContent>
|
|
237
|
+
</Dialog>
|
|
238
|
+
</Container>
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export default Lightbox;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import styled from 'styled-components';
|
|
2
|
+
|
|
3
|
+
export const Container = styled.div`
|
|
4
|
+
position: fixed;
|
|
5
|
+
top: 0;
|
|
6
|
+
left: 0;
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
9
|
+
display: flex;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
align-items: center;
|
|
12
|
+
z-index: 2000;
|
|
13
|
+
visibility: ${({ isOpen }) => (isOpen ? 'visible' : 'hidden')};
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export const Backdrop = styled.div`
|
|
17
|
+
position: absolute;
|
|
18
|
+
top: 0;
|
|
19
|
+
left: 0;
|
|
20
|
+
width: 100%;
|
|
21
|
+
height: 100%;
|
|
22
|
+
background: rgba(0, 0, 0, 0.8);
|
|
23
|
+
z-index: 0;
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export const Dialog = styled.dialog`
|
|
27
|
+
display: block;
|
|
28
|
+
padding: 0.5rem;
|
|
29
|
+
background: transparent;
|
|
30
|
+
border: none;
|
|
31
|
+
z-index: 1;
|
|
32
|
+
margin-top: 72px;
|
|
33
|
+
|
|
34
|
+
@media ${({ theme }) => theme.breakpoints2026('L')} {
|
|
35
|
+
margin-top: 84px;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
export const LightboxContent = styled.div`
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 1rem;
|
|
44
|
+
position: relative;
|
|
45
|
+
padding: 1rem;
|
|
46
|
+
background: #ffffff;
|
|
47
|
+
border-radius: 1rem;
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
export const LightboxImage = styled.div`
|
|
51
|
+
position: relative;
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
min-width: 128px;
|
|
56
|
+
min-height: 32px;
|
|
57
|
+
border-radius: 0.6rem;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
|
|
60
|
+
& > div {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
transition: width 0.3s ease-in-out, height 0.3s ease-in-out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
& img {
|
|
68
|
+
opacity: 0;
|
|
69
|
+
transition: opacity 0.1s ease-out 0.3s;
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
export const LightboxSpinner = styled.div`
|
|
74
|
+
position: absolute;
|
|
75
|
+
top: 50%;
|
|
76
|
+
left: 50%;
|
|
77
|
+
transform: translate(-50%, -50%);
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
export const LightboxDetails = styled.div`
|
|
81
|
+
display: flex;
|
|
82
|
+
flex-direction: column;
|
|
83
|
+
align-items: stretch;
|
|
84
|
+
gap: 0.5rem;
|
|
85
|
+
width: 100%;
|
|
86
|
+
padding: 0 1rem;
|
|
87
|
+
`;
|
|
88
|
+
|
|
89
|
+
export const NavButton = styled.button`
|
|
90
|
+
position: absolute;
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
width: 2.5rem;
|
|
95
|
+
height: 2.5rem;
|
|
96
|
+
border-radius: 0.5rem;
|
|
97
|
+
border: none;
|
|
98
|
+
background-color: white;
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
z-index: 10;
|
|
101
|
+
|
|
102
|
+
svg {
|
|
103
|
+
transition: all 0.1s ease-out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&:hover {
|
|
107
|
+
svg {
|
|
108
|
+
fill: ${({ theme }) => theme.color('red')};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&:focus-visible {
|
|
113
|
+
outline: 2px solid ${({ theme }) => theme.color('red')};
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
export const CloseButton = styled(NavButton)`
|
|
118
|
+
top: 0;
|
|
119
|
+
right: 0;
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
export const PreviousButton = styled(NavButton)`
|
|
123
|
+
top: 30%;
|
|
124
|
+
left: 0;
|
|
125
|
+
transform: translate(0, -50%);
|
|
126
|
+
border-top-left-radius: 0;
|
|
127
|
+
border-bottom-left-radius: 0;
|
|
128
|
+
|
|
129
|
+
@media ${({ theme }) => theme.breakpoints2026('L')} {
|
|
130
|
+
position: fixed;
|
|
131
|
+
top: 50%;
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
export const NextButton = styled(NavButton)`
|
|
136
|
+
top: 30%;
|
|
137
|
+
right: 0;
|
|
138
|
+
transform: translate(0, -50%);
|
|
139
|
+
border-top-right-radius: 0;
|
|
140
|
+
border-bottom-right-radius: 0;
|
|
141
|
+
|
|
142
|
+
@media ${({ theme }) => theme.breakpoints2026('L')} {
|
|
143
|
+
position: fixed;
|
|
144
|
+
top: 50%;
|
|
145
|
+
}
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
export const ScreenReaderOnly = styled.span`
|
|
149
|
+
position: absolute;
|
|
150
|
+
width: 1px;
|
|
151
|
+
height: 1px;
|
|
152
|
+
margin: -1px;
|
|
153
|
+
border: 0;
|
|
154
|
+
padding: 0;
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
clip-path: inset(100%);
|
|
157
|
+
clip: rect(0 0 0 0);
|
|
158
|
+
overflow: hidden;
|
|
159
|
+
`;
|