From d10cb096a1345d3228881cc3437d43c57ed4c010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87etin?= <69278826+cetincakiroglu@users.noreply.github.com> Date: Fri, 10 May 2024 13:21:25 +0300 Subject: [PATCH] Fixed #15482 - Rework and update focustrap --- src/app/components/dom/domhandler.ts | 75 ++++++++++++++++++++++- src/app/components/focustrap/focustrap.ts | 72 ++++++++++++++++++---- 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/app/components/dom/domhandler.ts b/src/app/components/dom/domhandler.ts index 5ee8c37821b..24b232e37ac 100755 --- a/src/app/components/dom/domhandler.ts +++ b/src/app/components/dom/domhandler.ts @@ -653,7 +653,7 @@ export class DomHandler { return null; } - public static getFirstFocusableElement(element, selector) { + public static getFirstFocusableElement(element, selector = '') { const focusableElements = this.getFocusableElements(element, selector); return focusableElements.length > 0 ? focusableElements[0] : null; @@ -765,4 +765,77 @@ export class DomHandler { document.body.style.removeProperty('--scrollbar-width'); this.removeClass(document.body, className); } + + public static createElement(type, attributes = {}, ...children) { + if (type) { + const element = document.createElement(type); + + this.setAttributes(element, attributes); + element.append(...children); + + return element; + } + + return undefined; + } + + public static setAttribute(element, attribute = '', value) { + if (this.isElement(element) && value !== null && value !== undefined) { + element.setAttribute(attribute, value); + } + } + + public static setAttributes(element, attributes = {}) { + if (this.isElement(element)) { + const computedStyles = (rule, value) => { + const styles = element?.$attrs?.[rule] ? [element?.$attrs?.[rule]] : []; + + return [value].flat().reduce((cv, v) => { + if (v !== null && v !== undefined) { + const type = typeof v; + + if (type === 'string' || type === 'number') { + cv.push(v); + } else if (type === 'object') { + const _cv = Array.isArray(v) + ? computedStyles(rule, v) + : Object.entries(v).map(([_k, _v]) => (rule === 'style' && (!!_v || _v === 0) ? `${_k.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}:${_v}` : !!_v ? _k : undefined)); + + cv = _cv.length ? cv.concat(_cv.filter((c) => !!c)) : cv; + } + } + + return cv; + }, styles); + }; + + Object.entries(attributes).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + const matchedEvent = key.match(/^on(.+)/); + + if (matchedEvent) { + element.addEventListener(matchedEvent[1].toLowerCase(), value); + } else if (key === 'pBind') { + this.setAttributes(element, value); + } else { + value = key === 'class' ? [...new Set(computedStyles('class', value))].join(' ').trim() : key === 'style' ? computedStyles('style', value).join(';').trim() : value; + (element.$attrs = element.$attrs || {}) && (element.$attrs[key] = value); + element.setAttribute(key, value); + } + } + }); + } + } + + public static isFocusableElement(element, selector = '') { + return this.isElement(element) + ? element.matches(`button:not([tabindex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}, + [href][clientHeight][clientWidth]:not([tabindex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}, + input:not([tabindex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}, + select:not([tabindex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}, + textarea:not([tabindex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}, + [tabIndex]:not([tabIndex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}, + [contenteditable]:not([tabIndex = "-1"]):not([disabled]):not([style*="display:none"]):not([hidden])${selector}`) + : false; + } } diff --git a/src/app/components/focustrap/focustrap.ts b/src/app/components/focustrap/focustrap.ts index a19afd62e60..71654d2ea66 100755 --- a/src/app/components/focustrap/focustrap.ts +++ b/src/app/components/focustrap/focustrap.ts @@ -1,7 +1,7 @@ import { DomHandler } from 'primeng/dom'; +import { CommonModule, DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { Directive, ElementRef, Input, NgModule, inject, booleanAttribute, PLATFORM_ID } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Directive, ElementRef, HostListener, Input, NgModule, inject, booleanAttribute } from '@angular/core'; /** * Focus Trap keeps focus within a certain DOM element while tabbing. * @group Components @@ -19,20 +19,68 @@ export class FocusTrap { */ @Input({ transform: booleanAttribute }) pFocusTrapDisabled: boolean = false; + platformId = inject(PLATFORM_ID); + host: ElementRef = inject(ElementRef); - @HostListener('keydown.tab', ['$event']) - @HostListener('keydown.shift.tab', ['$event']) - onkeydown(e: KeyboardEvent) { - if (this.pFocusTrapDisabled !== true) { - e.preventDefault(); - const focusableElement = DomHandler.getNextFocusableElement(this.host.nativeElement, e.shiftKey); - if (focusableElement) { - focusableElement.focus(); - focusableElement.select?.(); - } + document: Document = inject(DOCUMENT); + + firstHiddenFocusableElement!: HTMLElement; + + lastHiddenFocusableElement!: HTMLElement; + + ngOnInit() { + if (isPlatformBrowser(this.platformId) && !this.pFocusTrapDisabled) { + this.createHiddenFocusableElements(); } } + + getComputedSelector(selector) { + return `:not(.p-hidden-focusable):not([data-p-hidden-focusable="true"])${selector ?? ''}`; + } + + createHiddenFocusableElements() { + const tabindex = '0'; + + const createFocusableElement = (onFocus) => { + return DomHandler.createElement('span', { + class: 'p-hidden-accessible p-hidden-focusable', + tabindex, + role: 'presentation', + 'aria-hidden': true, + 'data-p-hidden-accessible': true, + 'data-p-hidden-focusable': true, + onFocus: onFocus?.bind(this) + }); + }; + + this.firstHiddenFocusableElement = createFocusableElement(this.onFirstHiddenElementFocus); + this.lastHiddenFocusableElement = createFocusableElement(this.onLastHiddenElementFocus); + + this.firstHiddenFocusableElement.setAttribute('data-pc-section', 'firstfocusableelement'); + this.lastHiddenFocusableElement.setAttribute('data-pc-section', 'lastfocusableelement'); + + this.host.nativeElement.prepend(this.firstHiddenFocusableElement); + this.host.nativeElement.append(this.lastHiddenFocusableElement); + } + + onFirstHiddenElementFocus(event) { + const { currentTarget, relatedTarget } = event; + const focusableElement = + relatedTarget === this.lastHiddenFocusableElement || !this.host.nativeElement?.contains(relatedTarget) ? DomHandler.getFirstFocusableElement(currentTarget.parentElement, ':not(.p-hidden-focusable)') : this.lastHiddenFocusableElement; + + DomHandler.focus(focusableElement); + } + + onLastHiddenElementFocus(event) { + const { currentTarget, relatedTarget } = event; + const focusableElement = + relatedTarget === this.firstHiddenFocusableElement || !this.host.nativeElement?.contains(relatedTarget) ? DomHandler.getLastFocusableElement(currentTarget.parentElement, ':not(.p-hidden-focusable)') : this.firstHiddenFocusableElement; + + DomHandler.focus(focusableElement); + } + + ngOnDestroy() {} } @NgModule({