diff --git a/SUPPORTED_BROWSERS.md b/SUPPORTED_BROWSERS.md
index f906905f42..f21333375c 100644
--- a/SUPPORTED_BROWSERS.md
+++ b/SUPPORTED_BROWSERS.md
@@ -2,13 +2,13 @@
> This file is auto-generated from `.browserslistrc` during the prebuild step. Do not edit manually.
-| Browser | Minimum version |
-| ---------------- | --------------- |
-| Android WebView | 145 or newer |
-| Apple Safari | 17 or newer |
-| Google Chrome | 117 or newer |
-| Microsoft Edge | 117 or newer |
-| Mozilla Firefox | 116 or newer |
-| Opera | 103 or newer |
-| Opera Mobile | 80 or newer |
-| Samsung Internet | 24 or newer |
+| Browser | Minimum version |
+|---------|-----------------|
+| Android WebView | 145 or newer |
+| Apple Safari | 17 or newer |
+| Google Chrome | 117 or newer |
+| Microsoft Edge | 117 or newer |
+| Mozilla Firefox | 116 or newer |
+| Opera | 103 or newer |
+| Opera Mobile | 80 or newer |
+| Samsung Internet | 24 or newer |
diff --git a/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html
index f3acbc600e..2d00483dd8 100644
--- a/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html
+++ b/projects/orcid-registry-ui/src/lib/components/import-works-dialog/import-works-dialog.component.html
@@ -105,16 +105,18 @@
{{ link.name }}
-
{{ connectedLabel }}
-
+
- ) => { code?: string; error?: Error }
- }
- const source = readFileSync(newRelicSourcePath, 'utf8')
- const result = minify_sync(source, {
- compress: true,
- mangle: true,
- format: { comments: false },
- })
- if (result.error) {
- throw result.error
- }
- if (!result.code) {
- throw new Error('terser returned empty code for new-relic.runtime.js')
+ newRelicCode = readFileSync(newRelicSourcePath, 'utf8')
+ return newRelicCode
+}
+
+function getNewRelicContentHash(): string {
+ if (newRelicContentHash !== undefined) {
+ return newRelicContentHash
}
- minifiedNewRelicJs = result.code
- return minifiedNewRelicJs
+ const code = getNewRelicCode()
+ newRelicContentHash = createHash('sha256').update(code).digest('hex').slice(0, 16)
+ return newRelicContentHash
}
export function newRelic(
indexHtml: string,
options: { folder: string; languageCode: string }
) {
+ const hash = getNewRelicContentHash()
writeFileSync(
- join(options.folder, 'new-relic.runtime.js'),
- getMinifiedNewRelicJs(),
+ join(options.folder, `new-relic.runtime.${hash}.js`),
+ getNewRelicCode(),
'utf8'
)
- const src = `new-relic.runtime-${options.languageCode}.js`
+ const src = `new-relic.runtime.${hash}-${options.languageCode}.js`
return indexHtml.replace(
'',
``
diff --git a/scripts/new-relic.runtime.js b/scripts/new-relic.runtime.js
index 42137bc485..93b05c79a0 100644
--- a/scripts/new-relic.runtime.js
+++ b/scripts/new-relic.runtime.js
@@ -1,8 +1,4 @@
window.NREUM || (NREUM = {})
-// Cost-saving: disable session replay and session trace (high $). We still record
-// all app events (addPageAction: registration journey, record expand, errors, etc.).
-// autoStart: false so no data is sent until NewRelicService calls newrelic.start()
-// only for users who are sampled (RUM togglz percentage).
NREUM.init = {
performance: { capture_measures: true },
browser_consent_mode: { enabled: false },
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 55f2445ae4..72e7d03303 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -16,6 +16,7 @@ import { TitleService } from './core/title-service/title.service'
import { ZendeskService } from './core/zendesk/zendesk.service'
import { ERROR_REPORT } from './errors'
import { NewRelicService } from './core/new-relic/new-relic.service'
+import { OneTrustAccessibilityService } from './core/onetrust/onetrust-accessibility.service'
@Component({
selector: 'app-root',
@@ -51,7 +52,8 @@ export class AppComponent {
private _errorHandler: ErrorHandlerService,
@Inject(WINDOW) private _window: Window,
_titleService: TitleService,
- _newRelicService: NewRelicService
+ _newRelicService: NewRelicService,
+ _oneTrustAccessibilityService: OneTrustAccessibilityService
) {
_titleService.init()
_platformInfo
@@ -84,6 +86,7 @@ export class AppComponent {
.subscribe()
_newRelicService.init()
+ _oneTrustAccessibilityService.init()
_router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
diff --git a/src/app/core/announcer/announcer.service.ts b/src/app/core/announcer/announcer.service.ts
index 4c8f31815d..f10001ed4a 100644
--- a/src/app/core/announcer/announcer.service.ts
+++ b/src/app/core/announcer/announcer.service.ts
@@ -12,6 +12,7 @@ export class AnnouncerService {
thereAreLabel = $localize`:@@shared.thereAre:There are`
onEachPageLabel = $localize`:@@shared.onEachPage:on each page`
showingLabel = $localize`:@@shared.showing:Showing`
+ oneTrustFocusAnnouncement = $localize`:@@layout.oneTrustFocusAnnouncement:Cookie settings dialog opened. Focus moved to this dialog. Please review the options and make a choice to continue.`
lastAnnouncement: string
liveAnnouncePagination(paginatorLabel: PageEvent, itemType: string) {
@@ -48,11 +49,11 @@ export class AnnouncerService {
if (this.lastAnnouncement !== announcement) {
this.lastAnnouncement = announcement
- this.announce(announcement)
+ this.liveAnnounce(announcement)
}
}
- private announce(announcement: string) {
+ liveAnnounce(announcement: string) {
if (runtimeEnvironment.debugger) {
console.debug('📢' + announcement)
}
diff --git a/src/app/core/onetrust/onetrust-accessibility.service.ts b/src/app/core/onetrust/onetrust-accessibility.service.ts
new file mode 100644
index 0000000000..7424890d00
--- /dev/null
+++ b/src/app/core/onetrust/onetrust-accessibility.service.ts
@@ -0,0 +1,290 @@
+import { DOCUMENT } from '@angular/common'
+import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'
+import { AnnouncerService } from '../announcer/announcer.service'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class OneTrustAccessibilityService implements OnDestroy {
+ private _document = inject(DOCUMENT)
+ private _announcerService = inject(AnnouncerService)
+ private _ngZone = inject(NgZone)
+
+ private _observer?: MutationObserver
+ private _activeDialog: HTMLElement | null = null
+ private _previouslyFocusedElement: HTMLElement | null = null
+ private _keyDownListener?: (event: KeyboardEvent) => void
+ private _focusInListener?: (event: FocusEvent) => void
+ private _policyLinkElement: HTMLElement | null = null
+ private _policyLinkOriginalTabIndex: string | null | undefined = undefined
+ private _expectedFocusAfterWrap: HTMLElement | null = null
+ private _expectedFocusExpiresAt = 0
+
+ init(): void {
+ if (!this._document?.body || this._observer) {
+ return
+ }
+
+ this._ngZone.runOutsideAngular(() => {
+ this._observer = new MutationObserver(() => this._syncDialogState())
+ this._observer.observe(this._document.body, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ attributeFilter: ['class', 'style', 'aria-hidden', 'hidden'],
+ })
+ this._syncDialogState()
+ })
+ }
+
+ ngOnDestroy(): void {
+ this._observer?.disconnect()
+ this._observer = undefined
+ this._removeFocusTrap()
+ }
+
+ private _syncDialogState(): void {
+ const dialog = this._findVisibleOneTrustDialog()
+
+ if (dialog) {
+ this._ensurePolicyLinkTabbable(dialog)
+ if (this._activeDialog !== dialog) {
+ this._activeDialog = dialog
+ this._focusDialogOnOpen(dialog)
+ this._installFocusTrap()
+ }
+ return
+ }
+
+ if (this._activeDialog) {
+ this._removeFocusTrap()
+ this._restorePolicyLinkTabIndex()
+ this._restorePreviousFocus()
+ this._activeDialog = null
+ }
+ }
+
+ private _findVisibleOneTrustDialog(): HTMLElement | null {
+ const selectors = [
+ '#onetrust-pc-sdk [role="dialog"]',
+ '#onetrust-banner-sdk .ot-sdk-container[role="dialog"]',
+ ]
+
+ for (const selector of selectors) {
+ const node = this._document.querySelector(selector)
+ if (node instanceof HTMLElement && this._isVisible(node)) {
+ return node
+ }
+ }
+
+ return null
+ }
+
+ private _focusDialogOnOpen(dialog: HTMLElement): void {
+ const active = this._document.activeElement
+ if (active instanceof HTMLElement && !dialog.contains(active)) {
+ this._previouslyFocusedElement = active
+ }
+
+ const tabbable = this._getTabbableElements(dialog)
+ const target = tabbable[0] ?? dialog
+ if (!target.hasAttribute('tabindex')) {
+ target.setAttribute('tabindex', '-1')
+ }
+ target.focus()
+ this._announcerService.liveAnnounce(
+ this._announcerService.oneTrustFocusAnnouncement
+ )
+ }
+
+ private _installFocusTrap(): void {
+ this._removeFocusTrap()
+
+ this._keyDownListener = (event: KeyboardEvent) => {
+ if (event.key !== 'Tab' || !this._activeDialog) {
+ return
+ }
+ // By default, do not carry wrap expectation across new key events.
+ this._expectedFocusAfterWrap = null
+ this._expectedFocusExpiresAt = 0
+
+ const dialog = this._activeDialog
+ if (!this._isVisible(dialog)) {
+ return
+ }
+
+ const tabbable = this._getTabbableElements(dialog)
+ if (!tabbable.length) {
+ dialog.focus()
+ this._preventEvent(event)
+ return
+ }
+
+ const first = tabbable[0]
+ const last = tabbable[tabbable.length - 1]
+ const activeElement = this._document.activeElement as HTMLElement | null
+ const isOutside = !activeElement || !dialog.contains(activeElement)
+
+ if (event.shiftKey) {
+ if (isOutside || activeElement === first) {
+ this._setExpectedFocusAfterWrap(last)
+ last.focus()
+ this._preventEvent(event)
+ }
+ return
+ }
+
+ if (isOutside || activeElement === last) {
+ this._setExpectedFocusAfterWrap(first)
+ first.focus()
+ this._preventEvent(event)
+ }
+ }
+
+ this._focusInListener = (event: FocusEvent) => {
+ if (!this._activeDialog) {
+ return
+ }
+
+ const dialog = this._activeDialog
+ if (!this._isVisible(dialog)) {
+ return
+ }
+
+ const target = event.target
+ if (target instanceof HTMLElement && dialog.contains(target)) {
+ if (
+ this._expectedFocusAfterWrap &&
+ Date.now() <= this._expectedFocusExpiresAt &&
+ target !== this._expectedFocusAfterWrap
+ ) {
+ this._expectedFocusAfterWrap.focus()
+ return
+ }
+ return
+ }
+
+ const tabbable = this._getTabbableElements(dialog)
+ const fallback = tabbable[0] ?? dialog
+ fallback.focus()
+ }
+
+ this._document.addEventListener('keydown', this._keyDownListener, true)
+ this._document.addEventListener('focusin', this._focusInListener, true)
+ }
+
+ private _removeFocusTrap(): void {
+ if (this._keyDownListener) {
+ this._document.removeEventListener('keydown', this._keyDownListener, true)
+ this._keyDownListener = undefined
+ }
+ if (this._focusInListener) {
+ this._document.removeEventListener('focusin', this._focusInListener, true)
+ this._focusInListener = undefined
+ }
+ }
+
+ private _restorePreviousFocus(): void {
+ if (
+ this._previouslyFocusedElement &&
+ this._document.contains(this._previouslyFocusedElement)
+ ) {
+ this._previouslyFocusedElement.focus()
+ }
+ this._previouslyFocusedElement = null
+ }
+
+ private _ensurePolicyLinkTabbable(dialog: HTMLElement): void {
+ const node = dialog.querySelector('.ot-cookie-policy-link')
+ if (!(node instanceof HTMLElement) || !this._isVisible(node)) {
+ return
+ }
+
+ if (this._policyLinkElement !== node) {
+ this._policyLinkElement = node
+ this._policyLinkOriginalTabIndex = node.getAttribute('tabindex')
+ }
+
+ if (node.getAttribute('tabindex') === '-1') {
+ node.setAttribute('tabindex', '0')
+ }
+ }
+
+ private _restorePolicyLinkTabIndex(): void {
+ if (!this._policyLinkElement || !this._document.contains(this._policyLinkElement)) {
+ this._policyLinkElement = null
+ this._policyLinkOriginalTabIndex = undefined
+ return
+ }
+
+ if (this._policyLinkOriginalTabIndex === null) {
+ this._policyLinkElement.removeAttribute('tabindex')
+ } else if (this._policyLinkOriginalTabIndex !== undefined) {
+ this._policyLinkElement.setAttribute(
+ 'tabindex',
+ this._policyLinkOriginalTabIndex
+ )
+ }
+
+ this._policyLinkElement = null
+ this._policyLinkOriginalTabIndex = undefined
+ }
+
+ private _getTabbableElements(container: HTMLElement): HTMLElement[] {
+ const selector = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(', ')
+
+ return Array.from(container.querySelectorAll(selector)).filter(
+ (node): node is HTMLElement =>
+ node instanceof HTMLElement &&
+ this._isVisible(node) &&
+ node.tabIndex >= 0 &&
+ !node.hasAttribute('disabled') &&
+ node.getAttribute('aria-hidden') !== 'true'
+ )
+ }
+
+ private _isVisible(element: HTMLElement): boolean {
+ if (!element.isConnected) {
+ return false
+ }
+
+ let current: HTMLElement | null = element
+ while (current) {
+ const style = this._document.defaultView?.getComputedStyle(current)
+ if (
+ style?.display === 'none' ||
+ style?.visibility === 'hidden' ||
+ current.getAttribute('aria-hidden') === 'true' ||
+ current.hasAttribute('hidden')
+ ) {
+ return false
+ }
+ current = current.parentElement
+ }
+
+ return element.getClientRects().length > 0
+ }
+
+ private _preventEvent(event: KeyboardEvent): void {
+ if (event.cancelable) {
+ try {
+ event.preventDefault()
+ } catch {
+ // Some runtime listeners are treated as passive by Zone/host; ignore and rely on focusin correction.
+ }
+ }
+ event.stopPropagation()
+ }
+
+ private _setExpectedFocusAfterWrap(target: HTMLElement): void {
+ this._expectedFocusAfterWrap = target
+ this._expectedFocusExpiresAt = Date.now() + 250
+ }
+}
diff --git a/src/locale/messages.lr.xlf b/src/locale/messages.lr.xlf
index 0db521b5c5..6a67ab26cb 100644
--- a/src/locale/messages.lr.xlf
+++ b/src/locale/messages.lr.xlf
@@ -6528,6 +6528,14 @@
LR
+
+ Cookie settings dialog opened. Focus moved to this dialog. Please review the options and make a choice to continue.
+
+ src/app/core/announcer/announcer.service.ts
+ 15
+
+ LR
+
ORCID record for
diff --git a/src/locale/messages.rl.xlf b/src/locale/messages.rl.xlf
index d2f775a235..bc54f4e61a 100644
--- a/src/locale/messages.rl.xlf
+++ b/src/locale/messages.rl.xlf
@@ -6528,6 +6528,14 @@
RL
+
+ Cookie settings dialog opened. Focus moved to this dialog. Please review the options and make a choice to continue.
+
+ src/app/core/announcer/announcer.service.ts
+ 15
+
+ RL
+
ORCID record for
diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf
index 1bcab2dabf..98f07430e2 100644
--- a/src/locale/messages.xlf
+++ b/src/locale/messages.xlf
@@ -5905,6 +5905,13 @@
209
+
+ Cookie settings dialog opened. Focus moved to this dialog. Please review the options and make a choice to continue.
+
+ src/app/core/announcer/announcer.service.ts
+ 15
+
+
ORCID record for
diff --git a/src/locale/messages.xx.xlf b/src/locale/messages.xx.xlf
index de607bbc16..e24e4d4ab5 100644
--- a/src/locale/messages.xx.xlf
+++ b/src/locale/messages.xx.xlf
@@ -6528,6 +6528,14 @@
X
+
+ Cookie settings dialog opened. Focus moved to this dialog. Please review the options and make a choice to continue.
+
+ src/app/core/announcer/announcer.service.ts
+ 15
+
+ X
+
ORCID record for