uikit-react-public 0.26.0 → 0.26.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.
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2
- import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { act, fireEvent, render, screen } from '@testing-library/react';
3
3
  import DrawerMenu from '../DrawerMenu';
4
4
  import Button from '../../Button';
5
5
  import { ThemeContextProvider } from '../../../theme/useTheme';
@@ -44,6 +44,7 @@ describe('DrawerMenu', () => {
44
44
  });
45
45
 
46
46
  afterEach(() => {
47
+ vi.useRealTimers();
47
48
  vi.restoreAllMocks();
48
49
  });
49
50
 
@@ -123,4 +124,95 @@ describe('DrawerMenu', () => {
123
124
  expect(window.getComputedStyle(drawer as Element).top).toBe('0px');
124
125
  expect(window.getComputedStyle(drawer as Element).height).toBe('100dvh');
125
126
  });
127
+
128
+ test('keeps tablet and desktop focus inside the header and drawer menu', () => {
129
+ render(
130
+ <ThemeContextProvider>
131
+ <header>
132
+ <DrawerMenu defaultOpen>
133
+ <Button aria-label='First drawer action'>First</Button>
134
+ <Button aria-label='Last drawer action'>Last</Button>
135
+ </DrawerMenu>
136
+ </header>
137
+ <Button aria-label='Outside action'>Outside</Button>
138
+ </ThemeContextProvider>
139
+ );
140
+
141
+ const trigger = screen.getByRole('button', { name: 'Close menu' });
142
+ const firstDrawerAction = screen.getByRole('button', {
143
+ name: 'First drawer action',
144
+ });
145
+ const lastDrawerAction = screen.getByRole('button', {
146
+ name: 'Last drawer action',
147
+ });
148
+ const outsideAction = screen.getByRole('button', {
149
+ name: 'Outside action',
150
+ });
151
+
152
+ lastDrawerAction.focus();
153
+ fireEvent.keyDown(document, { key: 'Tab' });
154
+ expect(trigger).toHaveFocus();
155
+ expect(outsideAction).not.toHaveFocus();
156
+
157
+ trigger.focus();
158
+ fireEvent.keyDown(document, {
159
+ key: 'Tab',
160
+ shiftKey: true,
161
+ });
162
+ expect(lastDrawerAction).toHaveFocus();
163
+ expect(firstDrawerAction).not.toHaveFocus();
164
+ });
165
+
166
+ test('resets drawer and menu content scroll positions after closing transition', () => {
167
+ vi.useFakeTimers();
168
+
169
+ render(
170
+ <ThemeContextProvider>
171
+ <header>
172
+ <DrawerMenu>
173
+ {({ close }) => (
174
+ <div>
175
+ <div
176
+ data-testid='scrollable-menu-content'
177
+ style={{ height: 100, overflow: 'auto' }}
178
+ >
179
+ <div style={{ height: 400 }}>Menu content</div>
180
+ </div>
181
+ <Button
182
+ aria-label='Close from content'
183
+ onClick={close}
184
+ >
185
+ Close
186
+ </Button>
187
+ </div>
188
+ )}
189
+ </DrawerMenu>
190
+ </header>
191
+ </ThemeContextProvider>
192
+ );
193
+
194
+ fireEvent.click(screen.getByRole('button', { name: 'Open menu' }));
195
+
196
+ const content = screen.getByTestId('ucl-uikit-dropdown__content');
197
+ const drawer = content.parentElement as HTMLElement;
198
+ const scrollableMenuContent = screen.getByTestId('scrollable-menu-content');
199
+
200
+ drawer.scrollTop = 120;
201
+ content.scrollTop = 80;
202
+ scrollableMenuContent.scrollTop = 60;
203
+
204
+ fireEvent.click(screen.getByRole('button', { name: 'Close from content' }));
205
+
206
+ expect(drawer.scrollTop).toBe(120);
207
+ expect(content.scrollTop).toBe(80);
208
+ expect(scrollableMenuContent.scrollTop).toBe(60);
209
+
210
+ act(() => {
211
+ vi.advanceTimersByTime(300);
212
+ });
213
+
214
+ expect(drawer.scrollTop).toBe(0);
215
+ expect(content.scrollTop).toBe(0);
216
+ expect(scrollableMenuContent.scrollTop).toBe(0);
217
+ });
126
218
  });
@@ -1,4 +1,11 @@
1
- import { HTMLAttributes, memo, RefObject, useEffect, useState } from 'react';
1
+ import {
2
+ HTMLAttributes,
3
+ memo,
4
+ RefObject,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
2
9
  import { createPortal } from 'react-dom';
3
10
  import { css, cx } from '@emotion/css';
4
11
  import { useTheme } from '../../theme';
@@ -22,8 +29,11 @@ export interface DropdownContentProps extends Omit<
22
29
  }
23
30
 
24
31
  const TABLET_BOTTOM_GAP_PX = 32;
32
+ const DRAWER_TRANSITION_MS = 300;
25
33
  const DEFAULT_DRAWER_BOUNDARY_SELECTOR =
26
34
  'header,[role="banner"],[data-drawer-menu-boundary]';
35
+ const FOCUSABLE_SELECTOR =
36
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
27
37
 
28
38
  const getCssLength = (value: number | string) =>
29
39
  typeof value === 'number' ? `${value}px` : value;
@@ -52,6 +62,58 @@ const DropdownContent = ({
52
62
  `(min-width: ${theme.breakpoints.tablet}px)`
53
63
  );
54
64
  const isDrawer = variant === 'drawer';
65
+ const wasOpenRef = useRef(isOpen);
66
+
67
+ useEffect(() => {
68
+ const wasOpen = wasOpenRef.current;
69
+ wasOpenRef.current = isOpen;
70
+
71
+ if (isOpen || !wasOpen) return;
72
+
73
+ const container = contentRef.current;
74
+ if (!container) return;
75
+
76
+ const resetScroll = () => {
77
+ const scrollableElements = [
78
+ container,
79
+ ...Array.from(container.querySelectorAll<HTMLElement>('*')),
80
+ ];
81
+
82
+ scrollableElements.forEach((element) => {
83
+ element.scrollTop = 0;
84
+ element.scrollLeft = 0;
85
+ });
86
+ };
87
+
88
+ if (!isDrawer) {
89
+ resetScroll();
90
+ return;
91
+ }
92
+
93
+ let hasResetScroll = false;
94
+
95
+ const resetScrollOnce = () => {
96
+ if (hasResetScroll) return;
97
+ hasResetScroll = true;
98
+ resetScroll();
99
+ };
100
+
101
+ const handleTransitionEnd = (event: TransitionEvent) => {
102
+ if (event.target !== container || event.propertyName !== 'transform') {
103
+ return;
104
+ }
105
+
106
+ resetScrollOnce();
107
+ };
108
+
109
+ container.addEventListener('transitionend', handleTransitionEnd);
110
+ const timeoutId = window.setTimeout(resetScrollOnce, DRAWER_TRANSITION_MS);
111
+
112
+ return () => {
113
+ container.removeEventListener('transitionend', handleTransitionEnd);
114
+ window.clearTimeout(timeoutId);
115
+ };
116
+ }, [contentRef, isDrawer, isOpen]);
55
117
 
56
118
  useEffect(() => {
57
119
  if (typeof document === 'undefined') return;
@@ -80,9 +142,7 @@ const DropdownContent = ({
80
142
 
81
143
  const getFocusableElements = () =>
82
144
  Array.from(
83
- container.querySelectorAll<HTMLElement>(
84
- 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
85
- )
145
+ container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
86
146
  ).filter((element) => !element.hasAttribute('disabled'));
87
147
 
88
148
  const focusableElements = getFocusableElements();
@@ -131,6 +191,78 @@ const DropdownContent = ({
131
191
  };
132
192
  }, [contentRef, isOpen, isTabletAndUp]);
133
193
 
194
+ useEffect(() => {
195
+ if (typeof document === 'undefined') return;
196
+ if (!isDrawer || !isTabletAndUp || !isOpen) return;
197
+
198
+ const container = contentRef.current;
199
+ if (!container) return;
200
+
201
+ const triggerElement = triggerRef.current;
202
+ const boundaryElement =
203
+ drawerBoundaryRef?.current ??
204
+ triggerElement?.closest<HTMLElement>(drawerBoundarySelector);
205
+ const focusContainers: HTMLElement[] = boundaryElement
206
+ ? [boundaryElement]
207
+ : [triggerElement, container].filter((element): element is HTMLElement =>
208
+ Boolean(element)
209
+ );
210
+
211
+ const getFocusableElements = () =>
212
+ Array.from(
213
+ new Set(
214
+ focusContainers.flatMap((focusContainer) =>
215
+ Array.from(
216
+ focusContainer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
217
+ )
218
+ )
219
+ )
220
+ ).filter((element) => !element.hasAttribute('disabled'));
221
+
222
+ const handleKeyDown = (event: KeyboardEvent) => {
223
+ if (event.key !== 'Tab') return;
224
+
225
+ const elements = getFocusableElements();
226
+ if (elements.length === 0) {
227
+ event.preventDefault();
228
+ container.focus();
229
+ return;
230
+ }
231
+
232
+ const first = elements[0];
233
+ const last = elements[elements.length - 1];
234
+ const active = document.activeElement as HTMLElement | null;
235
+
236
+ if (!active || !elements.includes(active)) {
237
+ event.preventDefault();
238
+ first.focus();
239
+ return;
240
+ }
241
+
242
+ if (event.shiftKey && active === first) {
243
+ event.preventDefault();
244
+ last.focus();
245
+ } else if (!event.shiftKey && active === last) {
246
+ event.preventDefault();
247
+ first.focus();
248
+ }
249
+ };
250
+
251
+ document.addEventListener('keydown', handleKeyDown);
252
+
253
+ return () => {
254
+ document.removeEventListener('keydown', handleKeyDown);
255
+ };
256
+ }, [
257
+ contentRef,
258
+ drawerBoundaryRef,
259
+ drawerBoundarySelector,
260
+ isDrawer,
261
+ isOpen,
262
+ isTabletAndUp,
263
+ triggerRef,
264
+ ]);
265
+
134
266
  useEffect(() => {
135
267
  if (typeof window === 'undefined') return;
136
268
  if (!isTabletAndUp || !isOpen || isDrawer) {
@@ -261,8 +393,8 @@ const DropdownContent = ({
261
393
  pointer-events: none;
262
394
  transform: translateX(100%);
263
395
  transition:
264
- transform 180ms ease-in-out,
265
- visibility 0ms linear 180ms;
396
+ transform ${DRAWER_TRANSITION_MS}ms ease-in,
397
+ visibility 0ms linear ${DRAWER_TRANSITION_MS}ms;
266
398
  will-change: transform;
267
399
 
268
400
  @media (min-width: ${theme.breakpoints.tablet}px) {
@@ -315,7 +447,7 @@ const DropdownContent = ({
315
447
  visibility: visible;
316
448
  pointer-events: auto;
317
449
  transform: translateX(0);
318
- transition: transform 180ms ease;
450
+ transition: transform ${DRAWER_TRANSITION_MS}ms ease-out;
319
451
  `;
320
452
 
321
453
  const style = cx(
@@ -206,36 +206,48 @@ const Footer = ({
206
206
  <a
207
207
  className={legalLinkStyle}
208
208
  href={disclaimer}
209
+ target="_blank"
210
+ rel="noopener noreferrer"
209
211
  >
210
212
  Disclaimer
211
213
  </a>
212
214
  <a
213
215
  className={legalLinkStyle}
214
216
  href={freedomOfInformation}
217
+ target="_blank"
218
+ rel="noopener noreferrer"
215
219
  >
216
220
  Freedom of Information
217
221
  </a>
218
222
  <a
219
223
  className={legalLinkStyle}
220
224
  href={accessibility}
225
+ target="_blank"
226
+ rel="noopener noreferrer"
221
227
  >
222
228
  Accessibility
223
229
  </a>
224
230
  <a
225
231
  className={legalLinkStyle}
226
232
  href={cookies}
233
+ target="_blank"
234
+ rel="noopener noreferrer"
227
235
  >
228
236
  Cookies
229
237
  </a>
230
238
  <a
231
239
  className={legalLinkStyle}
232
240
  href={privacy}
241
+ target="_blank"
242
+ rel="noopener noreferrer"
233
243
  >
234
244
  Privacy
235
245
  </a>
236
246
  <a
237
247
  className={legalLinkStyle}
238
248
  href={slaveryStatement}
249
+ target="_blank"
250
+ rel="noopener noreferrer"
239
251
  >
240
252
  Slavery statement
241
253
  </a>
@@ -254,36 +254,48 @@ exports[`Footer > snapshot: footer links provided 1`] = `
254
254
  <a
255
255
  class="css-16lix8i"
256
256
  href="https://dictionary.cambridge.org/dictionary/english/disclaimer"
257
+ rel="noopener noreferrer"
258
+ target="_blank"
257
259
  >
258
260
  Disclaimer
259
261
  </a>
260
262
  <a
261
263
  class="css-16lix8i"
262
264
  href="https://www.legislation.gov.uk/ukpga/2000/36/contents"
265
+ rel="noopener noreferrer"
266
+ target="_blank"
263
267
  >
264
268
  Freedom of Information
265
269
  </a>
266
270
  <a
267
271
  class="css-16lix8i"
268
272
  href="https://developer.mozilla.org/en-US/docs/Web/Accessibility"
273
+ rel="noopener noreferrer"
274
+ target="_blank"
269
275
  >
270
276
  Accessibility
271
277
  </a>
272
278
  <a
273
279
  class="css-16lix8i"
274
280
  href="https://en.wikipedia.org/wiki/HTTP_cookie"
281
+ rel="noopener noreferrer"
282
+ target="_blank"
275
283
  >
276
284
  Cookies
277
285
  </a>
278
286
  <a
279
287
  class="css-16lix8i"
280
288
  href="https://dictionary.cambridge.org/dictionary/english/privacy"
289
+ rel="noopener noreferrer"
290
+ target="_blank"
281
291
  >
282
292
  Privacy
283
293
  </a>
284
294
  <a
285
295
  class="css-16lix8i"
286
296
  href="https://modern-slavery-statement-registry.service.gov.uk/"
297
+ rel="noopener noreferrer"
298
+ target="_blank"
287
299
  >
288
300
  Slavery statement
289
301
  </a>
@@ -595,36 +607,48 @@ exports[`Footer > snapshot: nav links 1`] = `
595
607
  <a
596
608
  class="css-16lix8i"
597
609
  href="https://www.ucl.ac.uk/legal-services/disclaimer"
610
+ rel="noopener noreferrer"
611
+ target="_blank"
598
612
  >
599
613
  Disclaimer
600
614
  </a>
601
615
  <a
602
616
  class="css-16lix8i"
603
617
  href="https://www.ucl.ac.uk/foi"
618
+ rel="noopener noreferrer"
619
+ target="_blank"
604
620
  >
605
621
  Freedom of Information
606
622
  </a>
607
623
  <a
608
624
  class="css-16lix8i"
609
625
  href="https://www.ucl.ac.uk/accessibility"
626
+ rel="noopener noreferrer"
627
+ target="_blank"
610
628
  >
611
629
  Accessibility
612
630
  </a>
613
631
  <a
614
632
  class="css-16lix8i"
615
633
  href="https://www.ucl.ac.uk/legal-services/privacy/cookie-policy"
634
+ rel="noopener noreferrer"
635
+ target="_blank"
616
636
  >
617
637
  Cookies
618
638
  </a>
619
639
  <a
620
640
  class="css-16lix8i"
621
641
  href="https://www.ucl.ac.uk/legal-services/privacy"
642
+ rel="noopener noreferrer"
643
+ target="_blank"
622
644
  >
623
645
  Privacy
624
646
  </a>
625
647
  <a
626
648
  class="css-16lix8i"
627
649
  href="https://www.ucl.ac.uk/commercial-procurement/modern-day-slavery-statement"
650
+ rel="noopener noreferrer"
651
+ target="_blank"
628
652
  >
629
653
  Slavery statement
630
654
  </a>
@@ -887,36 +911,48 @@ exports[`Footer > snapshot: no nav links 1`] = `
887
911
  <a
888
912
  class="css-16lix8i"
889
913
  href="https://www.ucl.ac.uk/legal-services/disclaimer"
914
+ rel="noopener noreferrer"
915
+ target="_blank"
890
916
  >
891
917
  Disclaimer
892
918
  </a>
893
919
  <a
894
920
  class="css-16lix8i"
895
921
  href="https://www.ucl.ac.uk/foi"
922
+ rel="noopener noreferrer"
923
+ target="_blank"
896
924
  >
897
925
  Freedom of Information
898
926
  </a>
899
927
  <a
900
928
  class="css-16lix8i"
901
929
  href="https://www.ucl.ac.uk/accessibility"
930
+ rel="noopener noreferrer"
931
+ target="_blank"
902
932
  >
903
933
  Accessibility
904
934
  </a>
905
935
  <a
906
936
  class="css-16lix8i"
907
937
  href="https://www.ucl.ac.uk/legal-services/privacy/cookie-policy"
938
+ rel="noopener noreferrer"
939
+ target="_blank"
908
940
  >
909
941
  Cookies
910
942
  </a>
911
943
  <a
912
944
  class="css-16lix8i"
913
945
  href="https://www.ucl.ac.uk/legal-services/privacy"
946
+ rel="noopener noreferrer"
947
+ target="_blank"
914
948
  >
915
949
  Privacy
916
950
  </a>
917
951
  <a
918
952
  class="css-16lix8i"
919
953
  href="https://www.ucl.ac.uk/commercial-procurement/modern-day-slavery-statement"
954
+ rel="noopener noreferrer"
955
+ target="_blank"
920
956
  >
921
957
  Slavery statement
922
958
  </a>
@@ -100,36 +100,48 @@ const LegalAndCopyright = ({
100
100
  <a
101
101
  className={linkStyle}
102
102
  href={disclaimer}
103
+ target="_blank"
104
+ rel="noopener noreferrer"
103
105
  >
104
106
  Disclaimer
105
107
  </a>
106
108
  <a
107
109
  className={linkStyle}
108
110
  href={freedomOfInformation}
111
+ target="_blank"
112
+ rel="noopener noreferrer"
109
113
  >
110
114
  Freedom of Information
111
115
  </a>
112
116
  <a
113
117
  className={linkStyle}
114
118
  href={accessibility}
119
+ target="_blank"
120
+ rel="noopener noreferrer"
115
121
  >
116
122
  Accessibility
117
123
  </a>
118
124
  <a
119
125
  className={linkStyle}
120
126
  href={cookies}
127
+ target="_blank"
128
+ rel="noopener noreferrer"
121
129
  >
122
130
  Cookies
123
131
  </a>
124
132
  <a
125
133
  className={linkStyle}
126
134
  href={privacy}
135
+ target="_blank"
136
+ rel="noopener noreferrer"
127
137
  >
128
138
  Privacy
129
139
  </a>
130
140
  <a
131
141
  className={linkStyle}
132
142
  href={slaveryStatement}
143
+ target="_blank"
144
+ rel="noopener noreferrer"
133
145
  >
134
146
  Slavery statement
135
147
  </a>
@@ -137,6 +149,8 @@ const LegalAndCopyright = ({
137
149
  <a
138
150
  className={linkStyle}
139
151
  href={login}
152
+ target="_blank"
153
+ rel="noopener noreferrer"
140
154
  >
141
155
  Login
142
156
  </a>
@@ -306,36 +306,48 @@ exports[`Footer > snapshot: footer links provided 1`] = `
306
306
  <a
307
307
  class="css-19zp1gp"
308
308
  href="https://dictionary.cambridge.org/dictionary/english/disclaimer"
309
+ rel="noopener noreferrer"
310
+ target="_blank"
309
311
  >
310
312
  Disclaimer
311
313
  </a>
312
314
  <a
313
315
  class="css-19zp1gp"
314
316
  href="https://www.legislation.gov.uk/ukpga/2000/36/contents"
317
+ rel="noopener noreferrer"
318
+ target="_blank"
315
319
  >
316
320
  Freedom of Information
317
321
  </a>
318
322
  <a
319
323
  class="css-19zp1gp"
320
324
  href="https://developer.mozilla.org/en-US/docs/Web/Accessibility"
325
+ rel="noopener noreferrer"
326
+ target="_blank"
321
327
  >
322
328
  Accessibility
323
329
  </a>
324
330
  <a
325
331
  class="css-19zp1gp"
326
332
  href="https://en.wikipedia.org/wiki/HTTP_cookie"
333
+ rel="noopener noreferrer"
334
+ target="_blank"
327
335
  >
328
336
  Cookies
329
337
  </a>
330
338
  <a
331
339
  class="css-19zp1gp"
332
340
  href="https://dictionary.cambridge.org/dictionary/english/privacy"
341
+ rel="noopener noreferrer"
342
+ target="_blank"
333
343
  >
334
344
  Privacy
335
345
  </a>
336
346
  <a
337
347
  class="css-19zp1gp"
338
348
  href="https://modern-slavery-statement-registry.service.gov.uk/"
349
+ rel="noopener noreferrer"
350
+ target="_blank"
339
351
  >
340
352
  Slavery statement
341
353
  </a>
@@ -710,36 +722,48 @@ exports[`Footer > snapshot: nav links 1`] = `
710
722
  <a
711
723
  class="css-19zp1gp"
712
724
  href="https://www.ucl.ac.uk/legal-services/disclaimer"
725
+ rel="noopener noreferrer"
726
+ target="_blank"
713
727
  >
714
728
  Disclaimer
715
729
  </a>
716
730
  <a
717
731
  class="css-19zp1gp"
718
732
  href="https://www.ucl.ac.uk/foi"
733
+ rel="noopener noreferrer"
734
+ target="_blank"
719
735
  >
720
736
  Freedom of Information
721
737
  </a>
722
738
  <a
723
739
  class="css-19zp1gp"
724
740
  href="https://www.ucl.ac.uk/accessibility"
741
+ rel="noopener noreferrer"
742
+ target="_blank"
725
743
  >
726
744
  Accessibility
727
745
  </a>
728
746
  <a
729
747
  class="css-19zp1gp"
730
748
  href="https://www.ucl.ac.uk/legal-services/privacy/cookie-policy"
749
+ rel="noopener noreferrer"
750
+ target="_blank"
731
751
  >
732
752
  Cookies
733
753
  </a>
734
754
  <a
735
755
  class="css-19zp1gp"
736
756
  href="https://www.ucl.ac.uk/legal-services/privacy"
757
+ rel="noopener noreferrer"
758
+ target="_blank"
737
759
  >
738
760
  Privacy
739
761
  </a>
740
762
  <a
741
763
  class="css-19zp1gp"
742
764
  href="https://www.ucl.ac.uk/commercial-procurement/modern-day-slavery-statement"
765
+ rel="noopener noreferrer"
766
+ target="_blank"
743
767
  >
744
768
  Slavery statement
745
769
  </a>
@@ -1061,36 +1085,48 @@ exports[`Footer > snapshot: no nav links 1`] = `
1061
1085
  <a
1062
1086
  class="css-19zp1gp"
1063
1087
  href="https://www.ucl.ac.uk/legal-services/disclaimer"
1088
+ rel="noopener noreferrer"
1089
+ target="_blank"
1064
1090
  >
1065
1091
  Disclaimer
1066
1092
  </a>
1067
1093
  <a
1068
1094
  class="css-19zp1gp"
1069
1095
  href="https://www.ucl.ac.uk/foi"
1096
+ rel="noopener noreferrer"
1097
+ target="_blank"
1070
1098
  >
1071
1099
  Freedom of Information
1072
1100
  </a>
1073
1101
  <a
1074
1102
  class="css-19zp1gp"
1075
1103
  href="https://www.ucl.ac.uk/accessibility"
1104
+ rel="noopener noreferrer"
1105
+ target="_blank"
1076
1106
  >
1077
1107
  Accessibility
1078
1108
  </a>
1079
1109
  <a
1080
1110
  class="css-19zp1gp"
1081
1111
  href="https://www.ucl.ac.uk/legal-services/privacy/cookie-policy"
1112
+ rel="noopener noreferrer"
1113
+ target="_blank"
1082
1114
  >
1083
1115
  Cookies
1084
1116
  </a>
1085
1117
  <a
1086
1118
  class="css-19zp1gp"
1087
1119
  href="https://www.ucl.ac.uk/legal-services/privacy"
1120
+ rel="noopener noreferrer"
1121
+ target="_blank"
1088
1122
  >
1089
1123
  Privacy
1090
1124
  </a>
1091
1125
  <a
1092
1126
  class="css-19zp1gp"
1093
1127
  href="https://www.ucl.ac.uk/commercial-procurement/modern-day-slavery-statement"
1128
+ rel="noopener noreferrer"
1129
+ target="_blank"
1094
1130
  >
1095
1131
  Slavery statement
1096
1132
  </a>