UNPKG

@studiohyperdrive/ngx-inform

Version:

A lightweight ARIA compliant customizable approach for common and complex inform flows in Angular.

636 lines (624 loc) 26.7 kB
import * as i0 from '@angular/core'; import { InjectionToken, inject, Injectable, ElementRef, Input, HostBinding, HostListener, Directive, EventEmitter, Output } from '@angular/core'; import { v4 } from 'uuid'; import { Overlay, OverlayPositionBuilder } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { BehaviorSubject, Subject, pairwise, tap, takeUntil, NEVER, combineLatest, startWith, map, filter } from 'rxjs'; import { Dialog } from '@angular/cdk/dialog'; import { NgxWindowService } from '@studiohyperdrive/ngx-core'; /** * A token to provide the necessary configuration to the NgxTooltipDirective. This is exported * due to testing frameworks (like Storybook) not being able to resolve the InjectionToken in the * `provideNgxTooltipConfiguration`. */ const NgxTooltipConfigurationToken = new InjectionToken('NgxTooltipConfiguration'); /** * A token to provide the optional configuration to the NgxModalService */ // This is exported due to testing frameworks (like Storybook) not being able to resolve the InjectionToken in the `provideNgxTooltipConfiguration`. const NgxModalConfigurationToken = new InjectionToken('NgxModalConfiguration'); class NgxTooltipService { constructor() { this.configuration = inject(NgxTooltipConfigurationToken); this.overlayService = inject(Overlay); this.overlayPositionBuilder = inject(OverlayPositionBuilder); // Iben: The id of the active tooltip this.activeTooltip = undefined; /** * A subject to hold the tooltip events */ this.tooltipEventsSubject = new BehaviorSubject(undefined); /** * A subject to hold the destroy event */ this.onDestroySubject = new Subject(); /** * The position record for the tooltip */ this.positionRecord = { below: { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, above: { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, left: { originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center' }, right: { originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom' }, }; // Iben: Listen to the tooltip events and handle accordingly this.tooltipEventsSubject .pipe(pairwise(), tap(([previous, next]) => { // Iben: When we enter an element, we show the tooltip if (next.active && next.source === 'element') { // Iben: Check if we have a previous element, and if so, if we have to remove it if (previous && this.overlayRef?.hasAttached() && this.activeTooltip !== next.id) { this.removeToolTip(); } // Iben: Add the new tooltip const { component, text, position, elementRef, id } = next; this.showToolTip({ component: component, text: text, position: position, elementRef: elementRef, id: id, }); return; } // Iben: We do a check on previous here so we can continue safely in the upcoming checks if (!previous) { return; } // Iben: If we're entering a new element, we early exit if (previous.id !== next.id) { return; } // Iben: If the sources are the same, we check if we need to remove the tooltip // In this case we either leave the tooltip or leave the element if (previous.source === next.source) { if (!next.active) { this.removeToolTip(); return; } } // Iben: If both actives are false (element => tooltip => outside or tooltip => element => outside), we remove the tooltip if (!next.active && !previous.active) { this.removeToolTip(); } }), takeUntil(this.onDestroySubject.asObservable())) .subscribe(); } /** * Show a tooltip * * @param tooltip - The configuration of the tooltip */ showToolTip(tooltip) { // Iben: If no tooltip was provided or if we already have a tooltip attached, we early exit if (!tooltip || this.overlayRef?.hasAttached()) { return; } // Iben: Get the configuration of the tooltip const { text, component, position, elementRef, id } = tooltip; // Iben: Set the active tooltip this.activeTooltip = id; // Iben: Get the tooltip position. If no position was provided by the tooltip, we use the configured default, if none is configured we use 'above' const tooltipPosition = position || this.configuration.defaultPosition || 'above'; // Iben: If the previous overlayRef still exists, we remove it if (!this.overlayRef) { this.overlayRef = this.overlayService.create({ // Iben: Set the scroll strategy to reposition so that whenever the user scrolls, the tooltip is still near the element scrollStrategy: this.overlayService.scrollStrategies.reposition(), }); } // Iben: Create the position of the overlay const positionStrategy = this.overlayPositionBuilder .flexibleConnectedTo(elementRef) .withPositions([this.positionRecord[tooltipPosition]]); // Iben: Update the position of the current overlayRef this.overlayRef.updatePositionStrategy(positionStrategy); // Iben: Create a new component portal const tooltipPortal = new ComponentPortal(component || this.configuration.component); // Iben: Attach the tooltipPortal to the overlayRef const tooltipRef = this.overlayRef.attach(tooltipPortal); // Iben: Pass the data to the component const tooltipComponent = tooltipRef.instance; tooltipComponent.text = text; tooltipComponent.position = tooltipPosition; tooltipComponent.positionClass = `ngx-tooltip-position-${tooltipPosition}`; tooltipComponent.id = id; } /** * Removes the tooltip. */ removeToolTip() { if (this.activeTooltip) { // Iben: Unset the active tooltip this.activeTooltip = undefined; // Iben: Remove the active tooltip from view this.overlayRef.detach(); } } /** * Dispatches the tooltip event to the subject * * @param event - A tooltip event */ setToolTipEvent(event) { // Iben: We add a delay so that the user can navigate between the tooltip and the element setTimeout(() => { this.tooltipEventsSubject.next(event); }, event.active ? 0 : 100); } /** * Emit the destroy event */ ngOnDestroy() { this.onDestroySubject.next(); this.onDestroySubject.complete(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); /** * A wrapper service to Angular CDK Dialog that provides a WCAG/ARIA compliant implementation of modals * * @export * @class NgxModalService */ class NgxModalService { constructor() { this.configuration = inject(NgxModalConfigurationToken, { optional: true, }); this.dialogService = inject(Dialog); /** * A subject that keeps track of whether a modal is currently active */ this.hasModalSubject = new BehaviorSubject(false); /** * An observable that keeps track of whether a modal is currently active. */ this.hasActiveModal$ = this.hasModalSubject.asObservable(); } /** * Opens a modal based on the provided options * * @param {NgxModalOptions<ActionType>} options - The modal options */ open(options) { // Iben: If a previous modal is still active, we early exit. if (this.hasModalSubject.value) { console.warn('NgxInform: An active modal is currently displayed, close the active modal before opening a new one'); return NEVER; } // Iben: Declare the modal as active this.hasModalSubject.next(true); // Iben: Fetch the type of component we wish to show const configuration = this.configuration?.modals?.[options.type]; const component = options.component || configuration.component; // Iben: Check if all the correct parameters are set and return NEVER when they're not correctly set if (!this.runARIAChecks(options, component)) { return NEVER; } // Iben: Render the modal const modal = this.createModalComponent(options, component); // Iben: Return the modal action return combineLatest([ // Iben: Set the start value to undefined so both actions at least emit once modal.action.pipe(startWith(undefined)), modal.close.pipe( // Iben: Map so we can keep the emit value void, but can work with the filter later down the line map(() => 'NgxModalClose'), // Iben: Set the start value to undefined so both actions at least emit once startWith(undefined)), ]).pipe( // Iben: Only emit if one of the two actions actually has an emit filter(([action, closed]) => { return Boolean(action) || Boolean(closed); }), map(([action, closed]) => { return closed || action; }), tap((action) => { // Iben: If the autoClose is specifically set to false, we early exit unless we're running in a close event if (action !== 'NgxModalClose' && ((options.autoClose !== undefined && options.autoClose === false) || (configuration?.autoClose !== undefined && configuration.autoClose === false))) { return; } // Iben: Close the modal this.close(options.onClose); }), // Iben: Map the action back to the ActionType map((action) => { return action === 'NgxModalClose' ? undefined : action; }), // Wouter: Unsubscribe wen no modal is open takeUntil(this.hasModalSubject.pipe(filter((hasModal) => !hasModal)))); } /** * Closes the currently active modal * * * @param onClose - An optional onClose function */ close(onClose) { // Iben: Close the modal this.dialogService.closeAll(); // Wouter: The setTimeout delay is needed, so that the `open` method can emit before its subscription end gets triggered setTimeout(() => { // Iben: Mark the modal as closed this.hasModalSubject.next(false); }); // Iben: Run an optional onClose function if (onClose) { onClose(); } } /** * Checks if all the necessary preconditions are met * * @param options - The options of the modal * @param component - The component we wish to render */ runARIAChecks(options, component) { // Iben: If no component was found, we return NEVER and throw an error if (!component) { console.error('NgxInform: No component was provided or found in the configuration to render.'); return false; } // Iben: If no description was provided when required, we return NEVER and throw an error if (!this.hasRequiredDescription(options)) { console.error('NgxInform: The role of the modal was set to "alertdialog" but no "describedById" was provided.'); return false; } return true; } /** * Creates the modal component * * @param options - The options of the modal * @param component - The component we wish to render */ createModalComponent(options, component) { const configuration = this.configuration?.modals?.[options.type]; // Iben: Create the modal and render it const dialogRef = this.dialogService.open(component, { role: configuration?.role || options.role, ariaLabel: options.label, ariaLabelledBy: options.labelledById, ariaDescribedBy: options.describedById, disableClose: true, restoreFocus: this.getValue(undefined, options.restoreFocus, true), autoFocus: this.getValue(undefined, options.autoFocus, true), viewContainerRef: options.viewContainerRef, direction: configuration?.direction || options.direction, hasBackdrop: this.getValue(configuration?.hasBackdrop, options.hasBackdrop, true), panelClass: this.getValue(configuration?.panelClass, options.panelClass, []), closeOnNavigation: this.getValue(configuration?.closeOnNavigation, options.closeOnNavigation, true), closeOnDestroy: true, closeOnOverlayDetachments: true, }); const modal = dialogRef.componentInstance; // Iben: Set the data of the modal modal.data = this.getValue(configuration?.data, options.data, undefined); modal.ariaDescribedBy = options.describedById; modal.ariaLabelledBy = options.labelledById; return modal; } /** * Checks if the description is provided when the role requires it * * @param options - The options of the modal */ hasRequiredDescription(options) { // Iben: If the options has provided a default type, we check based on the configuration role if (options.type) { const configuration = this.configuration?.modals[options.type]; return !(configuration.role === 'alertdialog' && !options.describedById); } // Iben: Check based on the options role return !(options.role === 'alertdialog' && !options.describedById); } /** * Returns a value based on whether one of the overwrites is defined * * @private * @param configurationValue - The overwrite on configuration level * @param optionsValue - The overwrite on options level * @param defaultValue - The default value if no overwrite was defined */ getValue(configurationValue, optionsValue, defaultValue) { if (configurationValue === undefined && optionsValue === undefined) { return defaultValue; } if (optionsValue !== undefined) { return optionsValue; } return configurationValue; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxModalService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxModalService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxModalService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); /** * A directive that adds a ARIA compliant tooltip to a component * * @export * @class NgxTooltipDirective */ class NgxTooltipDirective { constructor() { this.tooltipService = inject(NgxTooltipService); this.elementRef = inject(ElementRef); /** * Make the item tabbable */ this.index = 0; /** * The id of the tooltip, unique in the DOM, required for accessibility. By default, this is an autogenerated UUID. */ this.ngxTooltipId = v4(); /** * Prevent the tooltip from being shown, by default this is false. */ this.ngxTooltipDisabled = false; } /** * Show the tooltip on hover */ showOnMouseEnter() { this.showTooltip(); } /** * Show the tooltip on focus */ showOnFocus() { this.showTooltip(); } /** * Remove the tooltip on leaving hover */ removeOnMouseOut() { this.removeTooltip(); } /** * Remove the tooltip on blur */ removeOnBlur() { this.removeTooltip(); } /** * Remove the tooltip on escape pressed */ onEscape() { this.tooltipService.removeToolTip(); } /** * Show the tooltip if it is not visible yet */ showTooltip() { // Iben: Early exit when the tooltip is disabled if (this.ngxTooltipDisabled) { return; } // Iben: Show the tooltip this.tooltipService.setToolTipEvent({ text: this.ngxTooltip, position: this.ngxTooltipPosition, component: this.ngxTooltipComponent, elementRef: this.elementRef, id: this.ngxTooltipId, source: 'element', active: true, }); } /** * Remove the tooltip */ removeTooltip() { // Iben: Early exit when the tooltip is disabled if (this.ngxTooltipDisabled) { return; } // Iben: Emit a remove event this.tooltipService.setToolTipEvent({ id: this.ngxTooltipId, source: 'element', active: false, }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.8", type: NgxTooltipDirective, isStandalone: true, selector: "[ngxTooltip]", inputs: { ngxTooltipId: "ngxTooltipId", ngxTooltip: "ngxTooltip", ngxTooltipComponent: "ngxTooltipComponent", ngxTooltipPosition: "ngxTooltipPosition", ngxTooltipDisabled: "ngxTooltipDisabled" }, host: { listeners: { "mouseenter": "showOnMouseEnter()", "focus": "showOnFocus()", "mouseleave": "removeOnMouseOut()", "blur": "removeOnBlur()", "document:keydown.escape": "onEscape()" }, properties: { "tabIndex": "this.index", "attr.aria-describedby": "this.ngxTooltipId" } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipDirective, decorators: [{ type: Directive, args: [{ selector: '[ngxTooltip]', standalone: true, }] }], propDecorators: { showOnMouseEnter: [{ type: HostListener, args: ['mouseenter'] }], showOnFocus: [{ type: HostListener, args: ['focus'] }], removeOnMouseOut: [{ type: HostListener, args: ['mouseleave'] }], removeOnBlur: [{ type: HostListener, args: ['blur'] }], onEscape: [{ type: HostListener, args: ['document:keydown.escape'] }], index: [{ type: HostBinding, args: ['tabIndex'] }], ngxTooltipId: [{ type: HostBinding, args: ['attr.aria-describedby'] }, { type: Input }], ngxTooltip: [{ type: Input, args: [{ required: true }] }], ngxTooltipComponent: [{ type: Input }], ngxTooltipPosition: [{ type: Input }], ngxTooltipDisabled: [{ type: Input }] } }); /** * Provides the configuration for the NgxTooltipDirective * * @param configuration - The required configuration */ const provideNgxTooltipConfiguration = (configuration) => { return { provide: NgxTooltipConfigurationToken, useValue: configuration, }; }; /** * Provides the configuration for the NgxModalService * * @param configuration - The required configuration */ const provideNgxModalConfiguration = (configuration) => { return { provide: NgxModalConfigurationToken, useValue: configuration, }; }; /** * An abstract for the NgxTooltipDirective */ class NgxTooltipAbstractComponent { /** * Set tooltip as active */ showOnMouseEnter() { this.ngxTooltipService.setToolTipEvent({ id: this.id, source: 'tooltip', active: true, }); } /** * Set the tooltip as inactive */ removeOnMouseOut() { this.ngxTooltipService.setToolTipEvent({ id: this.id, source: 'tooltip', active: false, }); } // eslint-disable-next-line @angular-eslint/prefer-inject constructor(ngxTooltipService) { this.ngxTooltipService = ngxTooltipService; /** * The role of the component */ this.role = 'tooltip'; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipAbstractComponent, deps: [{ token: NgxTooltipService }], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.8", type: NgxTooltipAbstractComponent, isStandalone: true, inputs: { positionClass: "positionClass", id: "id", position: "position", text: "text" }, host: { listeners: { "mouseenter": "showOnMouseEnter()", "mouseleave": "removeOnMouseOut()" }, properties: { "attr.role": "this.role", "class": "this.positionClass", "id": "this.id" } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxTooltipAbstractComponent, decorators: [{ type: Directive }], ctorParameters: () => [{ type: NgxTooltipService }], propDecorators: { showOnMouseEnter: [{ type: HostListener, args: ['mouseenter'] }], removeOnMouseOut: [{ type: HostListener, args: ['mouseleave'] }], role: [{ type: HostBinding, args: ['attr.role'] }], positionClass: [{ type: HostBinding, args: ['class'] }, { type: Input }], id: [{ type: HostBinding, args: ['id'] }, { type: Input, args: [{ required: true }] }], position: [{ type: Input, args: [{ required: true }] }], text: [{ type: Input, args: [{ required: true }] }] } }); /** * An abstract for the NgxModalService */ class NgxModalAbstractComponent { constructor() { this.windowService = inject(NgxWindowService); this.elementRef = inject(ElementRef); /** * An emitter that will emit an action we can later respond to */ this.action = new EventEmitter(); /** * An emitter that will emit if the modal is closed */ // eslint-disable-next-line @angular-eslint/no-output-native this.close = new EventEmitter(); } /** * Remove the modal on escape pressed */ onEscape() { this.close.emit(); } ngAfterViewInit() { // Iben: If we are in the browser, check if either of the two accessibility labels are set if (this.windowService.isBrowser() && (this.ariaLabelledBy || this.ariaDescribedBy)) { // Iben: Find the element with the id and the parent const element = document.getElementById(this.ariaLabelledBy || this.ariaDescribedBy); const parent = this.elementRef.nativeElement; // Iben: If no corresponding element was found or if it isn't part of the modal, throw an error if (!element || !parent.contains(element)) { console.error(`NgxModalAbstractComponent: The ${this.ariaLabelledBy ? '"aria-labelledBy"' : 'aria-describedBy'} property was passed to the modal but no element with said id was found. Because of that, the necessary accessibility attributes could not be set. Please add an id with the value "${this.ariaLabelledBy || this.ariaDescribedBy}" to an element of the modal.`); } } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxModalAbstractComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.8", type: NgxModalAbstractComponent, isStandalone: true, inputs: { ariaLabelledBy: "ariaLabelledBy", ariaDescribedBy: "ariaDescribedBy", data: "data" }, outputs: { action: "action", close: "close" }, host: { listeners: { "document:keydown.escape": "onEscape()" } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.8", ngImport: i0, type: NgxModalAbstractComponent, decorators: [{ type: Directive }], propDecorators: { onEscape: [{ type: HostListener, args: ['document:keydown.escape'] }], ariaLabelledBy: [{ type: Input }], ariaDescribedBy: [{ type: Input }], data: [{ type: Input }], action: [{ type: Output }], close: [{ type: Output }] } }); /** * Generated bundle index. Do not edit. */ export { NgxModalAbstractComponent, NgxModalConfigurationToken, NgxModalService, NgxTooltipAbstractComponent, NgxTooltipConfigurationToken, NgxTooltipDirective, provideNgxModalConfiguration, provideNgxTooltipConfiguration }; //# sourceMappingURL=studiohyperdrive-ngx-inform.mjs.map