@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,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
+ `;