@redacto.io/consent-sdk-react 0.0.1 → 0.0.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,27 +1,31 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useState, useRef } from "react";
2
2
  import { fetchConsentContent, submitConsentEvent } from "./api";
3
3
  import type { ConsentContent } from "./api/types";
4
4
  import type { Props, TranslationObject } from "./types";
5
5
  import { styles } from "./styles";
6
6
  import logo from "./assets/redacto-logo.png";
7
7
  import { useMediaQuery } from "./useMediaQuery";
8
+ import { injectCheckboxStyles } from "./injectStyles";
8
9
 
9
10
  export const RedactoNoticeConsent = ({
10
11
  noticeId,
11
12
  accessToken,
12
13
  refreshToken,
14
+ baseUrl,
13
15
  language = "en",
14
- blockUI = false,
16
+ blockUI = true,
15
17
  onAccept,
16
18
  onDecline,
17
19
  onError,
18
20
  settings,
21
+ applicationId,
19
22
  }: Props) => {
20
23
  const [content, setContent] = useState<
21
24
  ConsentContent["detail"]["active_config"] | null
22
25
  >(null);
23
26
  const [isLoading, setIsLoading] = useState(true);
24
27
  const [isSubmitting, setIsSubmitting] = useState(false);
28
+ const [isRefreshingToken, setIsRefreshingToken] = useState(false);
25
29
  const [isLanguageDropdownOpen, setIsLanguageDropdownOpen] = useState(false);
26
30
  const [selectedLanguage, setSelectedLanguage] = useState<string>(language);
27
31
  const [collapsedPurposes, setCollapsedPurposes] = useState<
@@ -34,6 +38,13 @@ export const RedactoNoticeConsent = ({
34
38
  Record<string, boolean>
35
39
  >({});
36
40
  const [hasAlreadyConsented, setHasAlreadyConsented] = useState(false);
41
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
42
+ const [fetchError, setFetchError] = useState<Error | null>(null);
43
+ const modalRef = useRef<HTMLDivElement>(null);
44
+ const firstFocusableRef = useRef<HTMLButtonElement>(null);
45
+ const lastFocusableRef = useRef<HTMLButtonElement>(null);
46
+ const MAX_REFRESH_ATTEMPTS = 1;
47
+ const [refreshAttempts, setRefreshAttempts] = useState(0);
37
48
 
38
49
  // Add media query hooks
39
50
  const isMobile = useMediaQuery("(max-width: 768px)");
@@ -86,6 +97,28 @@ export const RedactoNoticeConsent = ({
86
97
  middleSection: {
87
98
  paddingRight: isMobile ? "10px" : "15px",
88
99
  },
100
+ errorDialog: {
101
+ padding: isMobile ? "12px" : "16px",
102
+ maxWidth: isMobile ? "100%" : "400px",
103
+ marginBottom: isMobile ? "16px" : "20px",
104
+ },
105
+ errorTitle: {
106
+ fontSize: isMobile ? "16px" : "18px",
107
+ margin: isMobile ? "0 0 8px 0" : "0 0 12px 0",
108
+ },
109
+ errorMessage: {
110
+ fontSize: isMobile ? "12px" : "14px",
111
+ margin: isMobile ? "0 0 12px 0" : "0 0 16px 0",
112
+ },
113
+ errorButton: {
114
+ padding: isMobile ? "6px 12px" : "8px 16px",
115
+ fontSize: isMobile ? "12px" : "14px",
116
+ },
117
+ closeButton: {
118
+ top: isMobile ? "4px" : "8px",
119
+ right: isMobile ? "4px" : "8px",
120
+ padding: isMobile ? "2px" : "4px",
121
+ },
89
122
  }),
90
123
  [isMobile]
91
124
  );
@@ -157,71 +190,91 @@ export const RedactoNoticeConsent = ({
157
190
  return defaultText;
158
191
  };
159
192
 
160
- useEffect(() => {
161
- const fetchNotice = async () => {
162
- setIsLoading(true);
163
- try {
164
- const consentContentData = await fetchConsentContent({
165
- noticeId,
166
- accessToken,
167
- refreshToken,
168
- language,
169
- });
170
- setContent(consentContentData.detail.active_config);
171
- if (consentContentData.detail.active_config.default_language) {
172
- setSelectedLanguage(
173
- consentContentData.detail.active_config.default_language
174
- );
175
- }
176
- const initialCollapsedState =
177
- consentContentData.detail.active_config.purposes.reduce(
178
- (acc, purpose) => ({
193
+ const fetchNotice = async () => {
194
+ setIsLoading(true);
195
+ setFetchError(null);
196
+ try {
197
+ const consentContentData = await fetchConsentContent({
198
+ noticeId,
199
+ accessToken,
200
+ refreshToken,
201
+ baseUrl,
202
+ language,
203
+ specific_uuid: applicationId,
204
+ });
205
+ setContent(consentContentData.detail.active_config);
206
+ if (consentContentData.detail.active_config.default_language) {
207
+ setSelectedLanguage(
208
+ consentContentData.detail.active_config.default_language
209
+ );
210
+ }
211
+ const initialCollapsedState =
212
+ consentContentData.detail.active_config.purposes.reduce(
213
+ (acc, purpose) => ({
214
+ ...acc,
215
+ [purpose.uuid]: true,
216
+ }),
217
+ {}
218
+ );
219
+ setCollapsedPurposes(initialCollapsedState);
220
+
221
+ // Initialize selected states
222
+ const initialPurposeState: Record<string, boolean> =
223
+ consentContentData.detail.active_config.purposes.reduce(
224
+ (acc: Record<string, boolean>, purpose) => {
225
+ return {
179
226
  ...acc,
180
- [purpose.uuid]: true,
181
- }),
182
- {}
183
- );
184
- setCollapsedPurposes(initialCollapsedState);
185
-
186
- // Initialize selected states
187
- const initialPurposeState: Record<string, boolean> =
188
- consentContentData.detail.active_config.purposes.reduce(
189
- (acc: Record<string, boolean>, purpose) => {
190
- return {
191
- ...acc,
192
- [purpose.uuid]: false, // Initialize all purposes as unchecked
193
- };
194
- },
195
- {}
196
- );
197
- setSelectedPurposes(initialPurposeState);
198
-
199
- const initialDataElementState: Record<string, boolean> =
200
- consentContentData.detail.active_config.purposes.reduce(
201
- (acc: Record<string, boolean>, purpose) => {
202
- purpose.data_elements.forEach((element) => {
203
- const combinedId = `${purpose.uuid}-${element.uuid}`;
204
- acc[combinedId] = false; // Initialize all data elements as unchecked
205
- });
206
- return acc;
207
- },
208
- {}
209
- );
210
- setSelectedDataElements(initialDataElementState);
211
- } catch (err: unknown) {
212
- console.error(err);
213
- if ((err as Error & { status?: number }).status === 409) {
214
- setHasAlreadyConsented(true);
215
- } else {
216
- onError?.(err as Error);
217
- }
218
- } finally {
219
- setIsLoading(false);
227
+ [purpose.uuid]: false,
228
+ };
229
+ },
230
+ {}
231
+ );
232
+ setSelectedPurposes(initialPurposeState);
233
+
234
+ const initialDataElementState: Record<string, boolean> =
235
+ consentContentData.detail.active_config.purposes.reduce(
236
+ (acc: Record<string, boolean>, purpose) => {
237
+ purpose.data_elements.forEach((element) => {
238
+ const combinedId = `${purpose.uuid}-${element.uuid}`;
239
+ acc[combinedId] = false;
240
+ });
241
+ return acc;
242
+ },
243
+ {}
244
+ );
245
+ setSelectedDataElements(initialDataElementState);
246
+ } catch (err: unknown) {
247
+ console.error(err);
248
+ const error = err as Error & { status?: number };
249
+ setFetchError(error);
250
+ if (error.status === 401 && refreshAttempts < MAX_REFRESH_ATTEMPTS) {
251
+ setRefreshAttempts((c) => c + 1);
252
+ setIsRefreshingToken(true);
253
+ onError?.(error);
254
+ } else if (error.status === 409) {
255
+ setHasAlreadyConsented(true);
256
+ } else {
257
+ onError?.(error);
220
258
  }
221
- };
259
+ } finally {
260
+ setIsLoading(false);
261
+ setIsRefreshingToken(false);
262
+ }
263
+ };
222
264
 
265
+ useEffect(() => {
266
+ // Inject checkbox styles when component mounts
267
+ injectCheckboxStyles();
223
268
  fetchNotice();
224
- }, [noticeId, accessToken, refreshToken, language, onError]);
269
+ }, [
270
+ noticeId,
271
+ accessToken,
272
+ refreshToken,
273
+ language,
274
+ onError,
275
+ applicationId,
276
+ isRefreshingToken,
277
+ ]);
225
278
 
226
279
  const togglePurposeCollapse = (purposeUuid: string) => {
227
280
  setCollapsedPurposes((prev) => ({
@@ -301,9 +354,11 @@ export const RedactoNoticeConsent = ({
301
354
  const handleAccept = async () => {
302
355
  try {
303
356
  setIsSubmitting(true);
357
+ setErrorMessage(null);
304
358
  if (content) {
305
359
  await submitConsentEvent({
306
360
  accessToken,
361
+ baseUrl,
307
362
  noticeUuid: content.notice_uuid,
308
363
  purposes: content.purposes.map((purpose) => ({
309
364
  ...purpose,
@@ -317,11 +372,20 @@ export const RedactoNoticeConsent = ({
317
372
  })),
318
373
  })),
319
374
  declined: false,
375
+ meta_data: applicationId
376
+ ? { specific_uuid: applicationId }
377
+ : undefined,
320
378
  });
321
379
  }
322
380
  onAccept?.();
323
381
  } catch (error) {
324
382
  console.error("Error submitting consent:", error);
383
+ const err = error as Error & { status?: number };
384
+ if (err.status === 500) {
385
+ setErrorMessage(
386
+ "An error occurred while submitting your consent. Please try again later."
387
+ );
388
+ }
325
389
  onError?.(error as Error);
326
390
  } finally {
327
391
  setIsSubmitting(false);
@@ -378,7 +442,10 @@ export const RedactoNoticeConsent = ({
378
442
 
379
443
  const acceptButtonStyle = {
380
444
  ...buttonStyle,
381
- backgroundColor: settings?.button?.accept?.backgroundColor || "#4f87ff",
445
+ backgroundColor:
446
+ settings?.button?.accept?.backgroundColor ||
447
+ content?.primary_color ||
448
+ "#4f87ff",
382
449
  color: settings?.button?.accept?.textColor || "#ffffff",
383
450
  };
384
451
 
@@ -390,7 +457,7 @@ export const RedactoNoticeConsent = ({
390
457
  };
391
458
 
392
459
  const linkStyle = {
393
- color: settings?.link?.color || "#4f87ff",
460
+ color: settings?.link || content?.secondary_color || "#4f87ff",
394
461
  };
395
462
 
396
463
  const languageSelectorStyle = {
@@ -420,12 +487,74 @@ export const RedactoNoticeConsent = ({
420
487
  backgroundColor: settings?.backgroundColor || "#ffffff",
421
488
  };
422
489
 
490
+ const handleCloseError = () => {
491
+ setErrorMessage(null);
492
+ };
493
+
494
+ // Add keyboard event handler for the modal
495
+ useEffect(() => {
496
+ const handleKeyDown = (event: KeyboardEvent) => {
497
+ if (!modalRef.current) return;
498
+
499
+ const focusableElements = modalRef.current.querySelectorAll(
500
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
501
+ );
502
+ if (!focusableElements.length) return;
503
+
504
+ if (event.key === "Escape") {
505
+ handleDecline();
506
+ }
507
+
508
+ if (event.key === "Tab") {
509
+ const firstElement = focusableElements[0] as HTMLElement;
510
+ const lastElement = focusableElements[
511
+ focusableElements.length - 1
512
+ ] as HTMLElement;
513
+
514
+ if (event.shiftKey) {
515
+ if (document.activeElement === firstElement) {
516
+ event.preventDefault();
517
+ lastElement.focus();
518
+ }
519
+ } else {
520
+ if (document.activeElement === lastElement) {
521
+ event.preventDefault();
522
+ firstElement.focus();
523
+ }
524
+ }
525
+ }
526
+ };
527
+
528
+ const modal = modalRef.current;
529
+ modal?.addEventListener("keydown", handleKeyDown);
530
+
531
+ // Focus the first focusable element when modal opens
532
+ if (modal && !isLoading) {
533
+ const focusableElements = modal.querySelectorAll(
534
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
535
+ );
536
+ const firstElement = focusableElements[0] as HTMLElement;
537
+ firstElement?.focus();
538
+ }
539
+
540
+ return () => {
541
+ modal?.removeEventListener("keydown", handleKeyDown);
542
+ };
543
+ }, [isLoading, handleDecline]);
544
+
423
545
  return (
424
546
  <>
425
547
  {hasAlreadyConsented ? null : (
426
548
  <>
427
- {blockUI && <div style={styles.overlay} aria-hidden="true" />}
549
+ {blockUI && (
550
+ <div
551
+ style={styles.overlay}
552
+ aria-hidden="true"
553
+ role="presentation"
554
+ />
555
+ )}
428
556
  <div
557
+ ref={modalRef}
429
558
  style={{
430
559
  ...styles.modal,
431
560
  ...responsiveStyles.modal,
@@ -434,22 +563,178 @@ export const RedactoNoticeConsent = ({
434
563
  role="dialog"
435
564
  aria-modal="true"
436
565
  aria-labelledby="privacy-notice-title"
566
+ aria-describedby="privacy-notice-description"
567
+ tabIndex={-1}
437
568
  >
438
- {isLoading ? (
569
+ {isLoading || isRefreshingToken ? (
439
570
  <div style={styles.loadingContainer}>
440
571
  <div style={styles.loadingSpinner}></div>
441
572
  <p style={{ ...styles.privacyText, ...textStyle }}>
442
- Loading...
573
+ {isRefreshingToken ? "Refreshing session..." : "Loading..."}
443
574
  </p>
444
575
  </div>
576
+ ) : fetchError ? (
577
+ <div
578
+ style={{
579
+ ...styles.content,
580
+ ...responsiveStyles.content,
581
+ display: "flex",
582
+ flexDirection: "column",
583
+ alignItems: "center",
584
+ justifyContent: "center",
585
+ textAlign: "center",
586
+ padding: "40px 20px",
587
+ }}
588
+ >
589
+ <div
590
+ style={{
591
+ color: "#DC2626",
592
+ padding: responsiveStyles.errorDialog.padding,
593
+ borderRadius: "8px",
594
+ marginBottom: responsiveStyles.errorDialog.marginBottom,
595
+ width: "100%",
596
+ maxWidth: responsiveStyles.errorDialog.maxWidth,
597
+ position: "relative",
598
+ }}
599
+ >
600
+ <button
601
+ onClick={handleDecline}
602
+ style={{
603
+ position: "absolute",
604
+ top: responsiveStyles.closeButton.top,
605
+ right: responsiveStyles.closeButton.right,
606
+ background: "none",
607
+ border: "none",
608
+ color: "#DC2626",
609
+ cursor: "pointer",
610
+ padding: responsiveStyles.closeButton.padding,
611
+ display: "flex",
612
+ alignItems: "center",
613
+ justifyContent: "center",
614
+ }}
615
+ aria-label="Close error message"
616
+ >
617
+ <svg
618
+ width={isMobile ? "14" : "16"}
619
+ height={isMobile ? "14" : "16"}
620
+ viewBox="0 0 16 16"
621
+ fill="none"
622
+ xmlns="http://www.w3.org/2000/svg"
623
+ aria-hidden="true"
624
+ >
625
+ <path
626
+ d="M12 4L4 12M4 4L12 12"
627
+ stroke="currentColor"
628
+ strokeWidth="2"
629
+ strokeLinecap="round"
630
+ strokeLinejoin="round"
631
+ />
632
+ </svg>
633
+ </button>
634
+ <h3
635
+ style={{
636
+ margin: responsiveStyles.errorTitle.margin,
637
+ fontSize: responsiveStyles.errorTitle.fontSize,
638
+ fontWeight: "600",
639
+ }}
640
+ >
641
+ Error Loading Consent Notice
642
+ </h3>
643
+ <p
644
+ style={{
645
+ margin: responsiveStyles.errorMessage.margin,
646
+ fontSize: responsiveStyles.errorMessage.fontSize,
647
+ lineHeight: "1.5",
648
+ }}
649
+ >
650
+ {fetchError.message ||
651
+ "Failed to load consent notice. Please try again."}
652
+ </p>
653
+ <button
654
+ onClick={fetchNotice}
655
+ style={{
656
+ backgroundColor: "#DC2626",
657
+ color: "#FFFFFF",
658
+ border: "none",
659
+ padding: responsiveStyles.errorButton.padding,
660
+ borderRadius: "6px",
661
+ cursor: "pointer",
662
+ fontSize: responsiveStyles.errorButton.fontSize,
663
+ fontWeight: "500",
664
+ transition: "background-color 0.2s",
665
+ }}
666
+ onMouseOver={(e) =>
667
+ (e.currentTarget.style.backgroundColor = "#B91C1C")
668
+ }
669
+ onMouseOut={(e) =>
670
+ (e.currentTarget.style.backgroundColor = "#DC2626")
671
+ }
672
+ >
673
+ Refresh
674
+ </button>
675
+ </div>
676
+ </div>
445
677
  ) : (
446
678
  <div style={{ ...styles.content, ...responsiveStyles.content }}>
679
+ {errorMessage && (
680
+ <div
681
+ role="alert"
682
+ style={{
683
+ backgroundColor: "#FEE2E2",
684
+ color: "#DC2626",
685
+ padding: "12px",
686
+ borderRadius: "6px",
687
+ marginBottom: "16px",
688
+ fontSize: "14px",
689
+ border: "1px solid #FCA5A5",
690
+ position: "relative",
691
+ display: "flex",
692
+ alignItems: "center",
693
+ justifyContent: "space-between",
694
+ }}
695
+ >
696
+ <span>{errorMessage}</span>
697
+ <button
698
+ type="button"
699
+ onClick={handleCloseError}
700
+ style={{
701
+ background: "none",
702
+ border: "none",
703
+ color: "#DC2626",
704
+ cursor: "pointer",
705
+ padding: "4px",
706
+ marginLeft: "8px",
707
+ display: "flex",
708
+ alignItems: "center",
709
+ justifyContent: "center",
710
+ }}
711
+ aria-label="Close error message"
712
+ >
713
+ <svg
714
+ width="16"
715
+ height="16"
716
+ viewBox="0 0 16 16"
717
+ fill="none"
718
+ xmlns="http://www.w3.org/2000/svg"
719
+ aria-hidden="true"
720
+ >
721
+ <path
722
+ d="M12 4L4 12M4 4L12 12"
723
+ stroke="currentColor"
724
+ strokeWidth="2"
725
+ strokeLinecap="round"
726
+ strokeLinejoin="round"
727
+ />
728
+ </svg>
729
+ </button>
730
+ </div>
731
+ )}
447
732
  <div style={{ ...styles.topSection, ...sectionStyle }}>
448
733
  <div style={styles.topLeft}>
449
734
  <img
450
735
  style={styles.logo}
451
736
  src={content?.logo_url || logo}
452
- alt="logo"
737
+ alt="Redacto Logo"
453
738
  />
454
739
  <h2
455
740
  id="privacy-notice-title"
@@ -464,6 +749,7 @@ export const RedactoNoticeConsent = ({
464
749
  </div>
465
750
  <div style={styles.languageSelectorContainer}>
466
751
  <button
752
+ ref={firstFocusableRef}
467
753
  style={{
468
754
  ...styles.topRight,
469
755
  ...responsiveStyles.topRight,
@@ -472,6 +758,7 @@ export const RedactoNoticeConsent = ({
472
758
  onClick={toggleLanguageDropdown}
473
759
  aria-expanded={isLanguageDropdownOpen}
474
760
  aria-haspopup="listbox"
761
+ aria-controls="language-listbox"
475
762
  >
476
763
  {selectedLanguage}
477
764
  <svg
@@ -488,12 +775,14 @@ export const RedactoNoticeConsent = ({
488
775
  strokeLinecap="round"
489
776
  strokeLinejoin="round"
490
777
  style={{ flexShrink: 0, marginLeft: "4px" }}
778
+ aria-hidden="true"
491
779
  >
492
780
  <path d="M4 6l4 4 4-4" />
493
781
  </svg>
494
782
  </button>
495
783
  {isLanguageDropdownOpen && (
496
784
  <ul
785
+ id="language-listbox"
497
786
  style={{
498
787
  ...styles.languageDropdown,
499
788
  ...languageSelectorStyle,
@@ -525,6 +814,7 @@ export const RedactoNoticeConsent = ({
525
814
  </div>
526
815
  </div>
527
816
  <div
817
+ id="privacy-notice-description"
528
818
  style={{
529
819
  ...styles.middleSection,
530
820
  ...responsiveStyles.middleSection,
@@ -548,6 +838,7 @@ export const RedactoNoticeConsent = ({
548
838
  href={content?.privacy_policy_url || "#"}
549
839
  target="_blank"
550
840
  rel="noopener noreferrer"
841
+ aria-label="Privacy Policy (opens in new tab)"
551
842
  >
552
843
  [
553
844
  {getTranslatedText(
@@ -560,7 +851,9 @@ export const RedactoNoticeConsent = ({
560
851
  <a
561
852
  style={{ ...styles.link, ...linkStyle }}
562
853
  href={content?.sub_processors_url || "#"}
563
- target=""
854
+ target="_blank"
855
+ rel="noopener noreferrer"
856
+ aria-label="Vendors List (opens in new tab)"
564
857
  >
565
858
  [
566
859
  {getTranslatedText(
@@ -590,6 +883,16 @@ export const RedactoNoticeConsent = ({
590
883
  <div
591
884
  style={styles.optionLeft}
592
885
  onClick={() => togglePurposeCollapse(purpose.uuid)}
886
+ role="button"
887
+ tabIndex={0}
888
+ aria-expanded={!collapsedPurposes[purpose.uuid]}
889
+ aria-controls={`purpose-${purpose.uuid}`}
890
+ onKeyDown={(e) => {
891
+ if (e.key === "Enter" || e.key === " ") {
892
+ e.preventDefault();
893
+ togglePurposeCollapse(purpose.uuid);
894
+ }
895
+ }}
593
896
  >
594
897
  <div
595
898
  style={{
@@ -615,6 +918,7 @@ export const RedactoNoticeConsent = ({
615
918
  : "rotate(0deg)",
616
919
  transition: "transform 0.3s ease",
617
920
  }}
921
+ aria-hidden="true"
618
922
  >
619
923
  <path d="M1 1L6 6L1 11" />
620
924
  </svg>
@@ -649,18 +953,26 @@ export const RedactoNoticeConsent = ({
649
953
  </div>
650
954
  </div>
651
955
  <input
652
- style={styles.checkboxLarge}
956
+ className="redacto-checkbox-large"
653
957
  type="checkbox"
654
958
  checked={selectedPurposes[purpose.uuid] || false}
655
959
  onChange={() =>
656
960
  handlePurposeCheckboxChange(purpose.uuid)
657
961
  }
962
+ aria-label={`Select all data elements for ${getTranslatedText(
963
+ `purposes.name`,
964
+ purpose.name,
965
+ purpose.uuid
966
+ )}`}
658
967
  />
659
968
  </div>
660
969
  {!collapsedPurposes[purpose.uuid] &&
661
970
  purpose.data_elements &&
662
971
  purpose.data_elements.length > 0 && (
663
- <div style={styles.dataElementsContainer}>
972
+ <div
973
+ id={`purpose-${purpose.uuid}`}
974
+ style={styles.dataElementsContainer}
975
+ >
664
976
  {purpose.data_elements.map((dataElement) => (
665
977
  <div
666
978
  key={dataElement.uuid}
@@ -684,13 +996,14 @@ export const RedactoNoticeConsent = ({
684
996
  color: "red",
685
997
  marginLeft: "4px",
686
998
  }}
999
+ aria-label="required"
687
1000
  >
688
1001
  *
689
1002
  </span>
690
1003
  )}
691
1004
  </span>
692
1005
  <input
693
- style={styles.checkboxSmall}
1006
+ className="redacto-checkbox-small"
694
1007
  type="checkbox"
695
1008
  checked={
696
1009
  selectedDataElements[
@@ -703,6 +1016,11 @@ export const RedactoNoticeConsent = ({
703
1016
  purpose.uuid
704
1017
  )
705
1018
  }
1019
+ aria-label={`Select ${getTranslatedText(
1020
+ `data_elements.name`,
1021
+ dataElement.name,
1022
+ dataElement.uuid
1023
+ )}${dataElement.required ? " (required)" : ""}`}
706
1024
  />
707
1025
  </div>
708
1026
  ))}
@@ -720,6 +1038,7 @@ export const RedactoNoticeConsent = ({
720
1038
  }}
721
1039
  >
722
1040
  <button
1041
+ ref={lastFocusableRef}
723
1042
  style={{
724
1043
  ...styles.button,
725
1044
  ...responsiveStyles.button,
@@ -729,6 +1048,10 @@ export const RedactoNoticeConsent = ({
729
1048
  }}
730
1049
  onClick={handleAccept}
731
1050
  disabled={acceptDisabled}
1051
+ aria-label={getTranslatedText(
1052
+ "confirm_button_text",
1053
+ content?.confirm_button_text || "Accept"
1054
+ )}
732
1055
  >
733
1056
  {isSubmitting
734
1057
  ? "Processing..."
@@ -746,6 +1069,10 @@ export const RedactoNoticeConsent = ({
746
1069
  }}
747
1070
  onClick={handleDecline}
748
1071
  disabled={isSubmitting}
1072
+ aria-label={getTranslatedText(
1073
+ "decline_button_text",
1074
+ content?.decline_button_text || "Decline"
1075
+ )}
749
1076
  >
750
1077
  {isSubmitting
751
1078
  ? "Processing..."