@comicrelief/component-library 8.53.2 → 8.53.3
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/Picture/Picture.js +1 -0
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.js +43 -24
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.md +1 -1
- package/dist/components/Organisms/DynamicGallery/DynamicGallery.style.js +29 -16
- package/dist/components/Organisms/DynamicGallery/_DynamicGalleryColumn.js +8 -3
- package/dist/components/Organisms/DynamicGallery/_Lightbox.js +45 -24
- package/dist/components/Organisms/DynamicGallery/_Lightbox.style.js +7 -11
- package/dist/components/Organisms/DynamicGallery/__snapshots__/DynamicGallery.test.js.snap +254 -216
- package/package.json +1 -1
- package/playwright/components/organisms/dynamicGallery.spec.js +222 -1
- package/src/components/Atoms/Picture/Picture.js +1 -0
- package/src/components/Organisms/DynamicGallery/DynamicGallery.js +48 -25
- package/src/components/Organisms/DynamicGallery/DynamicGallery.md +1 -1
- package/src/components/Organisms/DynamicGallery/DynamicGallery.style.js +23 -1
- package/src/components/Organisms/DynamicGallery/_DynamicGalleryColumn.js +8 -3
- package/src/components/Organisms/DynamicGallery/_Lightbox.js +36 -19
- package/src/components/Organisms/DynamicGallery/_Lightbox.style.js +16 -29
- package/src/components/Organisms/DynamicGallery/__snapshots__/DynamicGallery.test.js.snap +254 -216
package/package.json
CHANGED
|
@@ -1,9 +1,230 @@
|
|
|
1
1
|
const { test, expect } = require('@playwright/test');
|
|
2
2
|
|
|
3
|
+
function hexToRgb(hex) {
|
|
4
|
+
// Remove the '#' if it's included in the input
|
|
5
|
+
const hexValue = hex.replace(/^#/, '');
|
|
6
|
+
|
|
7
|
+
// Parse the hex values into separate R, G, and B values
|
|
8
|
+
const red = parseInt(hexValue.substring(0, 2), 16);
|
|
9
|
+
const green = parseInt(hexValue.substring(2, 4), 16);
|
|
10
|
+
const blue = parseInt(hexValue.substring(4, 6), 16);
|
|
11
|
+
|
|
12
|
+
return `rgb(${red}, ${green}, ${blue})`;
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
test.describe('dynamic gallery component', () => {
|
|
4
|
-
test('
|
|
16
|
+
test('smoke test', async ({ page }) => {
|
|
5
17
|
await page.goto('/#dynamicgallery');
|
|
6
18
|
|
|
7
19
|
await page.close();
|
|
8
20
|
});
|
|
21
|
+
|
|
22
|
+
test('gallery column props', async ({ page }) => {
|
|
23
|
+
await page.goto('/#!/DynamicGallery/5');
|
|
24
|
+
|
|
25
|
+
// expect three gallery columns
|
|
26
|
+
await expect(page.locator('.gallery-column')).toHaveCount(4);
|
|
27
|
+
|
|
28
|
+
await page.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('responsive gallery columns', async ({ page }) => {
|
|
32
|
+
await page.goto('/#!/DynamicGallery/3');
|
|
33
|
+
|
|
34
|
+
// expect three gallery columns
|
|
35
|
+
await expect(page.locator('.gallery-column')).toHaveCount(3);
|
|
36
|
+
|
|
37
|
+
// resize the page to a small width
|
|
38
|
+
await page.setViewportSize({ width: 700, height: 1000 });
|
|
39
|
+
|
|
40
|
+
// expect one gallery column
|
|
41
|
+
await expect(page.locator('.gallery-column')).toHaveCount(2);
|
|
42
|
+
|
|
43
|
+
// resize the page to a medium width
|
|
44
|
+
await page.setViewportSize({ width: 320, height: 1000 });
|
|
45
|
+
|
|
46
|
+
// expect one gallery column
|
|
47
|
+
await expect(page.locator('.gallery-column')).toHaveCount(1);
|
|
48
|
+
|
|
49
|
+
await page.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('chunk mode test', async ({ page }) => {
|
|
53
|
+
await page.goto('/#!/DynamicGallery/3');
|
|
54
|
+
|
|
55
|
+
// expect 25 images to be visible
|
|
56
|
+
await expect(page.locator('.gallery-node')).toHaveCount(25);
|
|
57
|
+
|
|
58
|
+
// find the "load more" button and click it
|
|
59
|
+
await page.locator('button:has-text("Show more")').click();
|
|
60
|
+
|
|
61
|
+
// expect 50 images to be visible
|
|
62
|
+
await expect(page.locator('.gallery-node')).toHaveCount(50);
|
|
63
|
+
|
|
64
|
+
// expect the "load more" button to be hidden
|
|
65
|
+
await expect(page.locator('button:has-text("Show more")')).toBeHidden();
|
|
66
|
+
|
|
67
|
+
await page.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('non-chunk mode test', async ({ page }) => {
|
|
71
|
+
await page.goto('/#!/DynamicGallery/5');
|
|
72
|
+
|
|
73
|
+
// expect all 30 images to be visible
|
|
74
|
+
await expect(page.locator('.gallery-node')).toHaveCount(30);
|
|
75
|
+
|
|
76
|
+
// expect the "load more" button to be hidden
|
|
77
|
+
await expect(page.locator('button:has-text("Show more")')).toBeHidden();
|
|
78
|
+
|
|
79
|
+
await page.close();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('gallery tabbing', async ({ page }) => {
|
|
83
|
+
await page.goto('/#!/DynamicGallery/3');
|
|
84
|
+
|
|
85
|
+
// focus the first gallery node
|
|
86
|
+
await page.locator('.gallery-node').first().focus();
|
|
87
|
+
|
|
88
|
+
await page.waitForTimeout(3000);
|
|
89
|
+
|
|
90
|
+
// first tab should focus the first node in the first column
|
|
91
|
+
const firstNode = page.locator('.gallery-column').first().locator('.gallery-node').first();
|
|
92
|
+
await firstNode.focus();
|
|
93
|
+
await expect(firstNode).toBeFocused();
|
|
94
|
+
|
|
95
|
+
// tab to the first node in the second column
|
|
96
|
+
await page.keyboard.press('Tab');
|
|
97
|
+
const secondNode = page.locator('.gallery-column').nth(1).locator('.gallery-node').first();
|
|
98
|
+
await expect(secondNode).toBeFocused();
|
|
99
|
+
|
|
100
|
+
// tab back to the first node in the first column
|
|
101
|
+
await page.keyboard.press('Shift+Tab');
|
|
102
|
+
await expect(firstNode).toBeFocused();
|
|
103
|
+
|
|
104
|
+
await page.close();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('gallery tabbing should allow tabbing out of the gallery', async ({ page }) => {
|
|
108
|
+
await page.goto('/#!/DynamicGallery/5');
|
|
109
|
+
|
|
110
|
+
await page.waitForTimeout(3000);
|
|
111
|
+
|
|
112
|
+
// focus the first gallery node
|
|
113
|
+
const galleryNodes = await page.locator('.gallery-node').all();
|
|
114
|
+
await page.locator(`[data-node-index="${galleryNodes.length - 1}"]`).focus();
|
|
115
|
+
|
|
116
|
+
// press tab
|
|
117
|
+
await page.keyboard.press('Tab');
|
|
118
|
+
const galleryContainer = page.locator('.gallery-container');
|
|
119
|
+
|
|
120
|
+
// asset that the focus has moved outside the gallery
|
|
121
|
+
expect(
|
|
122
|
+
await galleryContainer.evaluate(
|
|
123
|
+
el => !el.contains(document.activeElement)
|
|
124
|
+
)
|
|
125
|
+
).toBe(true);
|
|
126
|
+
await page.close();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('custom page background and text colour', async ({ page }) => {
|
|
130
|
+
await page.goto('/#!/DynamicGallery/5');
|
|
131
|
+
|
|
132
|
+
const galleryContainer = page.locator('.gallery-container');
|
|
133
|
+
|
|
134
|
+
const backgroundColor = await galleryContainer.evaluate(el => window.getComputedStyle(el).getPropertyValue('background-color'));
|
|
135
|
+
expect(backgroundColor).toBe(hexToRgb('#0565D1'));
|
|
136
|
+
|
|
137
|
+
const textColor = await galleryContainer.evaluate(el => window.getComputedStyle(el).getPropertyValue('color'));
|
|
138
|
+
expect(textColor).toBe(hexToRgb('#FFFFFF'));
|
|
139
|
+
|
|
140
|
+
await page.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('check lightbox mode', async ({ page }) => {
|
|
144
|
+
await page.goto('/#!/DynamicGallery/3');
|
|
145
|
+
|
|
146
|
+
// find the first gallery node
|
|
147
|
+
const galleryNode = page.locator('.gallery-node').first();
|
|
148
|
+
|
|
149
|
+
// click it
|
|
150
|
+
await galleryNode.click();
|
|
151
|
+
|
|
152
|
+
// expect the lightbox to be visible
|
|
153
|
+
await expect(page.locator('dialog')).toBeVisible();
|
|
154
|
+
|
|
155
|
+
await page.waitForTimeout(1000);
|
|
156
|
+
await page.keyboard.press('Escape');
|
|
157
|
+
await expect(page.locator('dialog')).toBeHidden();
|
|
158
|
+
|
|
159
|
+
// focus the gallery node and press enter
|
|
160
|
+
await galleryNode.focus();
|
|
161
|
+
await page.keyboard.press('Enter');
|
|
162
|
+
|
|
163
|
+
// expect the lightbox to be visible
|
|
164
|
+
await expect(page.locator('dialog')).toBeVisible();
|
|
165
|
+
|
|
166
|
+
await page.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('lightbox navigation', async ({ page }) => {
|
|
170
|
+
await page.goto('/#!/DynamicGallery/3');
|
|
171
|
+
|
|
172
|
+
// find the first gallery node
|
|
173
|
+
const galleryNode = page.locator('.gallery-node').first();
|
|
174
|
+
|
|
175
|
+
// click it
|
|
176
|
+
await galleryNode.click();
|
|
177
|
+
|
|
178
|
+
// expect the lightbox and caption to be visible
|
|
179
|
+
await expect(page.locator('dialog')).toBeVisible();
|
|
180
|
+
await expect(page.getByText('Lightbox: This is the body for image 0')).toBeVisible();
|
|
181
|
+
|
|
182
|
+
await page.waitForTimeout(1000);
|
|
183
|
+
|
|
184
|
+
// navigate to the next image
|
|
185
|
+
await page.keyboard.press('ArrowRight');
|
|
186
|
+
await expect(page.getByText('Lightbox: This is the body for image 1')).toBeVisible();
|
|
187
|
+
|
|
188
|
+
await page.close();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('lightbox pointer close', async ({ page }) => {
|
|
192
|
+
await page.goto('/#!/DynamicGallery/3');
|
|
193
|
+
|
|
194
|
+
// find the first gallery node
|
|
195
|
+
const galleryNode = page.locator('.gallery-node').first();
|
|
196
|
+
|
|
197
|
+
// click it
|
|
198
|
+
await galleryNode.click();
|
|
199
|
+
|
|
200
|
+
// click the close button
|
|
201
|
+
await page.locator('.close-button').click();
|
|
202
|
+
|
|
203
|
+
// expect the lightbox to be hidden
|
|
204
|
+
await expect(page.locator('dialog')).toBeHidden();
|
|
205
|
+
|
|
206
|
+
await page.close();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('check non-lightbox mode', async ({ page }) => {
|
|
210
|
+
await page.goto('/#!/DynamicGallery/5');
|
|
211
|
+
|
|
212
|
+
// find the first gallery node
|
|
213
|
+
const galleryNode = page.locator('.gallery-node').first();
|
|
214
|
+
|
|
215
|
+
// click it
|
|
216
|
+
await galleryNode.click();
|
|
217
|
+
|
|
218
|
+
// expect the lightbox to be hidden
|
|
219
|
+
await expect(page.locator('dialog')).toBeHidden();
|
|
220
|
+
|
|
221
|
+
// focus the gallery node and press enter
|
|
222
|
+
await galleryNode.focus();
|
|
223
|
+
await page.keyboard.press('Enter');
|
|
224
|
+
|
|
225
|
+
// expect the lightbox to be hidden
|
|
226
|
+
await expect(page.locator('dialog')).toBeHidden();
|
|
227
|
+
|
|
228
|
+
await page.close();
|
|
229
|
+
});
|
|
9
230
|
});
|
|
@@ -12,7 +12,8 @@ import Lightbox, { LightboxContext } from './_Lightbox';
|
|
|
12
12
|
import {
|
|
13
13
|
Container,
|
|
14
14
|
EmptyMessage,
|
|
15
|
-
ImageGrid
|
|
15
|
+
ImageGrid,
|
|
16
|
+
FocusTrap
|
|
16
17
|
} from './DynamicGallery.style';
|
|
17
18
|
import DynamicGalleryColumn from './_DynamicGalleryColumn';
|
|
18
19
|
import { GalleryNodeType } from './_types';
|
|
@@ -58,8 +59,9 @@ const DynamicGallery = ({
|
|
|
58
59
|
const { top, left } = node.getBoundingClientRect();
|
|
59
60
|
return floor(top, -2) + Math.floor(left) / 1000;
|
|
60
61
|
}, 'asc');
|
|
62
|
+
|
|
61
63
|
sortedNodes.forEach((galleryNode, index) => {
|
|
62
|
-
galleryNode.setAttribute('data-order', String(index));
|
|
64
|
+
galleryNode.setAttribute('data-tab-order', String(index));
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
// create a throttled version of the updateTabOrder function
|
|
@@ -100,16 +102,30 @@ const DynamicGallery = ({
|
|
|
100
102
|
const [selectedNode, setSelectedNode] = useState(null);
|
|
101
103
|
const [focusedNode, setFocusedNode] = useState(null);
|
|
102
104
|
|
|
103
|
-
// handle next/previous node events from the lightbox
|
|
105
|
+
// handle next/previous node events from the lightbox;
|
|
106
|
+
// slightly complicated because we need to use the data-tab-order attribute
|
|
107
|
+
// to navigate between nodes rather than the node index;
|
|
108
|
+
// this reflects the tab ordering in the DOM, rather than the order of the nodes in the array,
|
|
109
|
+
// because the dynamic image heights can confuse the normal order
|
|
104
110
|
function handleNextNode(node) {
|
|
105
111
|
const nodeIndex = nodes.indexOf(node);
|
|
106
|
-
const
|
|
107
|
-
|
|
112
|
+
const nodeEl = containerRef.current.querySelector(`[data-node-index="${nodeIndex}"]`);
|
|
113
|
+
const tabOrder = nodeEl.getAttribute('data-tab-order');
|
|
114
|
+
const nextTabOrder = (+tabOrder + 1) % imageCount;
|
|
115
|
+
const nextNodeEl = containerRef.current.querySelector(`[data-tab-order="${nextTabOrder}"]`);
|
|
116
|
+
const nextNodeIndex = nextNodeEl.getAttribute('data-node-index');
|
|
117
|
+
const nextNode = nodes[nextNodeIndex];
|
|
118
|
+
setSelectedNode(nextNode);
|
|
108
119
|
}
|
|
109
120
|
function handlePreviousNode(node) {
|
|
110
121
|
const nodeIndex = nodes.indexOf(node);
|
|
111
|
-
const
|
|
112
|
-
|
|
122
|
+
const nodeEl = containerRef.current.querySelector(`[data-node-index="${nodeIndex}"]`);
|
|
123
|
+
const tabOrder = nodeEl.getAttribute('data-tab-order');
|
|
124
|
+
const previousTabOrder = (+tabOrder - 1 + imageCount) % imageCount;
|
|
125
|
+
const previousNodeEl = containerRef.current.querySelector(`[data-tab-order="${previousTabOrder}"]`);
|
|
126
|
+
const previousNodeIndex = previousNodeEl.getAttribute('data-node-index');
|
|
127
|
+
const previousNode = nodes[previousNodeIndex];
|
|
128
|
+
setSelectedNode(previousNode);
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
// handle keydown events,
|
|
@@ -139,7 +155,7 @@ const DynamicGallery = ({
|
|
|
139
155
|
// - flex-column+order > no gaps but complex (https://mui.com/material-ui/react-masonry/)
|
|
140
156
|
// - columns + custom tabbing > what we're doing here
|
|
141
157
|
case 'Tab': {
|
|
142
|
-
const nodeIndex = +event.target.dataset.
|
|
158
|
+
const nodeIndex = +event.target.dataset.tabOrder;
|
|
143
159
|
if (Number.isNaN(nodeIndex)) return;
|
|
144
160
|
const galleryContainer = event.target.closest('.gallery-container');
|
|
145
161
|
if (!galleryContainer) return;
|
|
@@ -151,7 +167,7 @@ const DynamicGallery = ({
|
|
|
151
167
|
newNodeIndex = nodeIndex - 1;
|
|
152
168
|
if (newNodeIndex < 0) return;
|
|
153
169
|
event.preventDefault();
|
|
154
|
-
galleryContainer.querySelector(`[data-order="${newNodeIndex}"]`).focus();
|
|
170
|
+
galleryContainer.querySelector(`[data-tab-order="${newNodeIndex}"]`).focus();
|
|
155
171
|
} else {
|
|
156
172
|
// tab: move to the next image
|
|
157
173
|
newNodeIndex = nodeIndex + 1;
|
|
@@ -166,7 +182,7 @@ const DynamicGallery = ({
|
|
|
166
182
|
return;
|
|
167
183
|
}
|
|
168
184
|
event.preventDefault();
|
|
169
|
-
galleryContainer.querySelector(`[data-order="${newNodeIndex}"]`).focus();
|
|
185
|
+
galleryContainer.querySelector(`[data-tab-order="${newNodeIndex}"]`).focus();
|
|
170
186
|
}
|
|
171
187
|
break;
|
|
172
188
|
}
|
|
@@ -200,26 +216,33 @@ const DynamicGallery = ({
|
|
|
200
216
|
{hasNodes
|
|
201
217
|
&& Array(columnCount)
|
|
202
218
|
.fill(null)
|
|
203
|
-
.map((column, columnIndex) =>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
219
|
+
.map((column, columnIndex) => {
|
|
220
|
+
// eslint prefers template literals for strings, but they break the compiler
|
|
221
|
+
// eslint-disable-next-line prefer-template
|
|
222
|
+
const key = String(columnIndex) + ':' + nodes.length;
|
|
223
|
+
return (
|
|
224
|
+
<DynamicGalleryColumn
|
|
225
|
+
// disabling the lint rule here
|
|
226
|
+
// as we're chunking an array and have no unique keys
|
|
227
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
228
|
+
key={key}
|
|
229
|
+
columnIndex={columnIndex}
|
|
230
|
+
columnCount={columnCount}
|
|
231
|
+
nodes={nodes.slice(0, imageCount)}
|
|
232
|
+
imageRatio={imageRatio}
|
|
233
|
+
updateTabOrder={throttledUpdateTabOrder.current}
|
|
234
|
+
focusOutlineColour={textColour}
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
217
238
|
|
|
218
239
|
<EmptyMessage isEmpty={!hasNodes}>No images to display</EmptyMessage>
|
|
219
240
|
</ImageGrid>
|
|
220
241
|
<Lightbox />
|
|
221
242
|
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
|
|
222
|
-
<
|
|
243
|
+
<FocusTrap className="gallery-focus-trap" tabIndex={0}>
|
|
244
|
+
<span>End of gallery</span>
|
|
245
|
+
</FocusTrap>
|
|
223
246
|
</LightboxContext.Provider>
|
|
224
247
|
{imageCount < nodes.length && <Button onClick={() => handleLoadMore()}>Show more</Button>}
|
|
225
248
|
</Container>
|
|
@@ -18,7 +18,7 @@ import createMockGalleryNodes from './_utils';
|
|
|
18
18
|
```js
|
|
19
19
|
const defaultData = require('../../../styleguide/data/data').defaultData;
|
|
20
20
|
import createMockGalleryNodes from './_utils';
|
|
21
|
-
<DynamicGallery gridWidth={4} nodes={createMockGalleryNodes(
|
|
21
|
+
<DynamicGallery gridWidth={4} nodes={createMockGalleryNodes(30)} loadingBehaviour="all" imageRatio="4:3" pageBackgroundColour="blue" textColour="white" paddingTop="6rem" paddingBottom="6rem" useLightbox={false} />;
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
### Gallery with max 5 columns
|
|
@@ -5,10 +5,15 @@ export const Container = styled.div`
|
|
|
5
5
|
flex-direction: column;
|
|
6
6
|
align-items: center;
|
|
7
7
|
gap: 1rem;
|
|
8
|
+
position: relative;
|
|
8
9
|
max-width: ${({ maxWidth }) => maxWidth};
|
|
9
10
|
background: ${({ theme, pageBackgroundColour }) => theme.color(pageBackgroundColour)};
|
|
10
|
-
${({ paddingTop, paddingBottom }) =>
|
|
11
|
+
padding: ${({ paddingTop, paddingBottom }) => `${paddingTop} 1rem ${paddingBottom}`};
|
|
11
12
|
color: ${({ theme, textColour }) => theme.color(textColour)};
|
|
13
|
+
|
|
14
|
+
@media ${({ theme }) => theme.breakpoints2026('M')} {
|
|
15
|
+
gap: 1rem;
|
|
16
|
+
}
|
|
12
17
|
`;
|
|
13
18
|
|
|
14
19
|
export const ImageGrid = styled.div`
|
|
@@ -97,3 +102,20 @@ export const Details = styled.div`
|
|
|
97
102
|
gap: 0.5rem;
|
|
98
103
|
padding: 0 1rem;
|
|
99
104
|
`;
|
|
105
|
+
|
|
106
|
+
export const ScreenReaderOnly = styled.span`
|
|
107
|
+
position: absolute;
|
|
108
|
+
width: 1px;
|
|
109
|
+
height: 1px;
|
|
110
|
+
margin: -1px;
|
|
111
|
+
border: 0;
|
|
112
|
+
padding: 0;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
clip-path: inset(100%);
|
|
115
|
+
clip: rect(0 0 0 0);
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
export const FocusTrap = styled(ScreenReaderOnly)`
|
|
120
|
+
bottom: 0;
|
|
121
|
+
`;
|
|
@@ -91,10 +91,16 @@ export default function DynamicGalleryColumn({
|
|
|
91
91
|
return (
|
|
92
92
|
<Column ref={elRef} className="gallery-column">
|
|
93
93
|
{nodes
|
|
94
|
-
?.filter((_, nodeIndex) => nodeIndex % columnCount === columnIndex)
|
|
95
94
|
.map((node, nodeIndex) => {
|
|
95
|
+
// only render nodes that are in the current column;
|
|
96
|
+
// this lets us assign a unique index to each node
|
|
97
|
+
const columnNodeIndex = nodeIndex % columnCount;
|
|
98
|
+
if (columnNodeIndex !== columnIndex) return null;
|
|
99
|
+
|
|
96
100
|
const bodyText = extractNodeText(node.gridBody);
|
|
97
|
-
|
|
101
|
+
// eslint prefers template literals for strings, but they break the compiler
|
|
102
|
+
// eslint-disable-next-line prefer-template
|
|
103
|
+
const key = String(nodeIndex) + ':' + bodyText + ':' + node.image;
|
|
98
104
|
return (
|
|
99
105
|
<NodeComponent
|
|
100
106
|
key={key}
|
|
@@ -109,7 +115,6 @@ export default function DynamicGalleryColumn({
|
|
|
109
115
|
>
|
|
110
116
|
<ImageContainer
|
|
111
117
|
className="gallery-node-image"
|
|
112
|
-
// eslint prefers template literals for strings, but they break the compiler
|
|
113
118
|
// eslint-disable-next-line prefer-template
|
|
114
119
|
minHeight={String(minHeight) + 'px'}
|
|
115
120
|
// eslint-disable-next-line prefer-template
|
|
@@ -15,9 +15,9 @@ import {
|
|
|
15
15
|
LightboxImage,
|
|
16
16
|
LightboxSpinner,
|
|
17
17
|
NextButton,
|
|
18
|
-
PreviousButton
|
|
19
|
-
ScreenReaderOnly
|
|
18
|
+
PreviousButton
|
|
20
19
|
} from './_Lightbox.style';
|
|
20
|
+
import { ScreenReaderOnly } from './DynamicGallery.style';
|
|
21
21
|
import ScrollFix from './_ScrollFix';
|
|
22
22
|
import { extractNodeText } from './_utils';
|
|
23
23
|
|
|
@@ -68,6 +68,20 @@ const Lightbox = () => {
|
|
|
68
68
|
const hasNode = Boolean(selectedNode);
|
|
69
69
|
const dialogRef = useRef(null);
|
|
70
70
|
|
|
71
|
+
// handle interaction type
|
|
72
|
+
const interactionTypeRef = useRef('keyboard');
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
function handlePointerDown() {
|
|
76
|
+
interactionTypeRef.current = 'pointer';
|
|
77
|
+
document.removeEventListener('pointerdown', handlePointerDown);
|
|
78
|
+
}
|
|
79
|
+
document.addEventListener('pointerdown', handlePointerDown);
|
|
80
|
+
return () => {
|
|
81
|
+
document.removeEventListener('pointerdown', handlePointerDown);
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
71
85
|
/**
|
|
72
86
|
* handle keyboard events within the lightbox;
|
|
73
87
|
* - trapped focus between UI elements
|
|
@@ -128,13 +142,15 @@ const Lightbox = () => {
|
|
|
128
142
|
// handle focus management when dialog opens/closes
|
|
129
143
|
useEffect(() => {
|
|
130
144
|
if (hasNode) {
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
145
|
+
// when the lightbox opens, optionally focus the close button,
|
|
146
|
+
// but only if the user is interacting via the keyboard;
|
|
147
|
+
// we check interaction type because although focus-visible should handle this,
|
|
148
|
+
// Safari on iOS always shows the focus ring
|
|
149
|
+
requestAnimationFrame(() => {
|
|
150
|
+
if (interactionTypeRef.current === 'keyboard') {
|
|
151
|
+
dialogRef.current.querySelector('.close-button').focus();
|
|
136
152
|
}
|
|
137
|
-
}
|
|
153
|
+
});
|
|
138
154
|
} else {
|
|
139
155
|
// restore focus to the previously focused element when lightbox closes
|
|
140
156
|
focusedNode?.focus();
|
|
@@ -166,8 +182,8 @@ const Lightbox = () => {
|
|
|
166
182
|
const scaleX = maxWidth / imageWidth;
|
|
167
183
|
const scaleY = maxHeight / imageHeight;
|
|
168
184
|
const scale = Math.min(scaleX, scaleY);
|
|
169
|
-
const width = imageWidth * scale;
|
|
170
|
-
const height = imageHeight * scale;
|
|
185
|
+
const width = Math.round(imageWidth * scale);
|
|
186
|
+
const height = Math.round(imageHeight * scale);
|
|
171
187
|
|
|
172
188
|
// set the width and height on the image element, and make it visible
|
|
173
189
|
setImageDimensions({ width: `${width}px`, height: `${height}px` });
|
|
@@ -201,8 +217,17 @@ const Lightbox = () => {
|
|
|
201
217
|
height={imageDimensions.height}
|
|
202
218
|
objectFit="contain"
|
|
203
219
|
onLoad={event => onLoad(event)}
|
|
220
|
+
style={{ borderRadius: '0.6rem', overflow: 'hidden' }}
|
|
204
221
|
/>
|
|
205
222
|
)}
|
|
223
|
+
<PreviousButton type="button" onClick={() => previousNode(selectedNode)}>
|
|
224
|
+
<ScreenReaderOnly>Previous</ScreenReaderOnly>
|
|
225
|
+
<Arrow direction="left" colour="black" size={16} />
|
|
226
|
+
</PreviousButton>
|
|
227
|
+
<NextButton type="button" onClick={() => nextNode(selectedNode)}>
|
|
228
|
+
<ScreenReaderOnly>Next</ScreenReaderOnly>
|
|
229
|
+
<Arrow direction="right" colour="black" size={16} />
|
|
230
|
+
</NextButton>
|
|
206
231
|
</LightboxImage>
|
|
207
232
|
<LightboxDetails id="lightboxDescription" aria-live="polite" aria-atomic="true">
|
|
208
233
|
{selectedNode?.lightboxBody && (
|
|
@@ -216,18 +241,10 @@ const Lightbox = () => {
|
|
|
216
241
|
</div>
|
|
217
242
|
)}
|
|
218
243
|
</LightboxDetails>
|
|
219
|
-
<CloseButton type="button" onClick={() => setSelectedNode(null)}>
|
|
244
|
+
<CloseButton className="close-button" type="button" onClick={() => setSelectedNode(null)}>
|
|
220
245
|
<ScreenReaderOnly>Close</ScreenReaderOnly>
|
|
221
246
|
<Cross colour="black" size={16} />
|
|
222
247
|
</CloseButton>
|
|
223
|
-
<PreviousButton type="button" onClick={() => previousNode(selectedNode)}>
|
|
224
|
-
<ScreenReaderOnly>Previous</ScreenReaderOnly>
|
|
225
|
-
<Arrow direction="left" colour="black" size={16} />
|
|
226
|
-
</PreviousButton>
|
|
227
|
-
<NextButton type="button" onClick={() => nextNode(selectedNode)}>
|
|
228
|
-
<ScreenReaderOnly>Next</ScreenReaderOnly>
|
|
229
|
-
<Arrow direction="right" colour="black" size={16} />
|
|
230
|
-
</NextButton>
|
|
231
248
|
</LightboxContent>
|
|
232
249
|
</Dialog>
|
|
233
250
|
</Container>
|
|
@@ -54,8 +54,7 @@ export const LightboxImage = styled.div`
|
|
|
54
54
|
justify-content: center;
|
|
55
55
|
min-width: 128px;
|
|
56
56
|
min-height: 32px;
|
|
57
|
-
|
|
58
|
-
overflow: hidden;
|
|
57
|
+
width: 100%;
|
|
59
58
|
|
|
60
59
|
& > div {
|
|
61
60
|
display: flex;
|
|
@@ -83,6 +82,7 @@ export const LightboxDetails = styled.div`
|
|
|
83
82
|
align-items: stretch;
|
|
84
83
|
gap: 0.5rem;
|
|
85
84
|
width: 100%;
|
|
85
|
+
max-width: 1024px;
|
|
86
86
|
padding: 0 1rem;
|
|
87
87
|
`;
|
|
88
88
|
|
|
@@ -100,16 +100,18 @@ export const NavButton = styled.button`
|
|
|
100
100
|
z-index: 10;
|
|
101
101
|
|
|
102
102
|
svg {
|
|
103
|
-
transition:
|
|
103
|
+
transition: fill 0.1s ease-out;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
@media (hover: hover) {
|
|
107
|
+
&:hover {
|
|
108
|
+
svg {
|
|
109
|
+
fill: ${({ theme }) => theme.color('red')};
|
|
110
|
+
}
|
|
109
111
|
}
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
:focus-visible {
|
|
113
115
|
outline: 2px solid ${({ theme }) => theme.color('red')};
|
|
114
116
|
}
|
|
115
117
|
`;
|
|
@@ -120,40 +122,25 @@ export const CloseButton = styled(NavButton)`
|
|
|
120
122
|
`;
|
|
121
123
|
|
|
122
124
|
export const PreviousButton = styled(NavButton)`
|
|
123
|
-
top:
|
|
125
|
+
top: 50%;
|
|
124
126
|
left: 0;
|
|
125
|
-
transform: translate(
|
|
126
|
-
border-top-left-radius: 0;
|
|
127
|
-
border-bottom-left-radius: 0;
|
|
127
|
+
transform: translate(-1rem, -50%);
|
|
128
128
|
|
|
129
129
|
@media ${({ theme }) => theme.breakpoints2026('L')} {
|
|
130
130
|
position: fixed;
|
|
131
|
+
transform: translate(1rem, -50%);
|
|
131
132
|
top: 50%;
|
|
132
133
|
}
|
|
133
134
|
`;
|
|
134
135
|
|
|
135
136
|
export const NextButton = styled(NavButton)`
|
|
136
|
-
top:
|
|
137
|
+
top: 50%;
|
|
137
138
|
right: 0;
|
|
138
|
-
transform: translate(
|
|
139
|
-
border-top-right-radius: 0;
|
|
140
|
-
border-bottom-right-radius: 0;
|
|
139
|
+
transform: translate(1rem, -50%);
|
|
141
140
|
|
|
142
141
|
@media ${({ theme }) => theme.breakpoints2026('L')} {
|
|
143
142
|
position: fixed;
|
|
144
|
-
|
|
143
|
+
transform: translate(-1rem, -50%);
|
|
144
|
+
top: 50%;
|
|
145
145
|
}
|
|
146
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
|
-
`;
|