@studiohyperdrive/ngx-inform
Version:
A lightweight ARIA compliant customizable approach for common and complex inform flows in Angular.
643 lines (631 loc) • 27.6 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, Injectable, Inject, Optional, Directive, HostListener, HostBinding, Input, EventEmitter, PLATFORM_ID, Output } from '@angular/core';
import { UUID } from 'angular2-uuid';
import { ComponentPortal } from '@angular/cdk/portal';
import { BehaviorSubject, Subject, pairwise, tap, takeUntil, NEVER, combineLatest, startWith, map, filter } from 'rxjs';
import * as i1 from '@angular/cdk/overlay';
import * as i1$1 from '@angular/cdk/dialog';
import { isPlatformBrowser } from '@angular/common';
/**
* 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(configuration, overlayService, overlayPositionBuilder) {
this.configuration = configuration;
this.overlayService = overlayService;
this.overlayPositionBuilder = 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: "19.0.3", ngImport: i0, type: NgxTooltipService, deps: [{ token: NgxTooltipConfigurationToken }, { token: i1.Overlay }, { token: i1.OverlayPositionBuilder }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxTooltipService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxTooltipService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Inject,
args: [NgxTooltipConfigurationToken]
}] }, { type: i1.Overlay }, { type: i1.OverlayPositionBuilder }] });
/**
* A wrapper service to Angular CDK Dialog that provides a WCAG/ARIA compliant implementation of modals
*
* @export
* @class NgxModalService
*/
class NgxModalService {
constructor(configuration, dialogService) {
this.configuration = configuration;
this.dialogService = dialogService;
/**
* 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: "19.0.3", ngImport: i0, type: NgxModalService, deps: [{ token: NgxModalConfigurationToken, optional: true }, { token: i1$1.Dialog }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxModalService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxModalService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [NgxModalConfigurationToken]
}] }, { type: i1$1.Dialog }] });
/**
* A directive that adds a ARIA compliant tooltip to a component
*
* @export
* @class NgxTooltipDirective
*/
class NgxTooltipDirective {
/**
* 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();
}
constructor(tooltipService, elementRef) {
this.tooltipService = tooltipService;
this.elementRef = 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 = UUID.UUID();
/**
* Prevent the tooltip from being shown, by default this is false.
*/
this.ngxTooltipDisabled = false;
}
/**
* 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: "19.0.3", ngImport: i0, type: NgxTooltipDirective, deps: [{ token: NgxTooltipService }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.3", 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: "19.0.3", ngImport: i0, type: NgxTooltipDirective, decorators: [{
type: Directive,
args: [{
selector: '[ngxTooltip]',
standalone: true,
}]
}], ctorParameters: () => [{ type: NgxTooltipService }, { type: i0.ElementRef }], 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,
});
}
constructor(ngxTooltipService) {
this.ngxTooltipService = ngxTooltipService;
/**
* The role of the component
*/
this.role = 'tooltip';
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxTooltipAbstractComponent, deps: [{ token: NgxTooltipService }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.3", type: NgxTooltipAbstractComponent, isStandalone: true, inputs: { positionClass: "positionClass", id: "id", position: "position", text: "text" }, host: { listeners: { "mouseenter": "showOnMouseEnter()", "mouseleave": "removeOnMouseOut()" }, properties: { "role": "this.role", "class": "this.positionClass", "id": "this.id" } }, ngImport: i0 }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", 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: ['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 {
/**
* Remove the modal on escape pressed
*/
onEscape() {
this.close.emit();
}
constructor(platformId, elementRef) {
this.platformId = platformId;
this.elementRef = 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
*/
this.close = new EventEmitter();
}
ngAfterViewInit() {
// Iben: If we are in the browser, check if either of the two accessibility labels are set
if (isPlatformBrowser(this.platformId) && (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: "19.0.3", ngImport: i0, type: NgxModalAbstractComponent, deps: [{ token: PLATFORM_ID }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); }
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.0.3", 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: "19.0.3", ngImport: i0, type: NgxModalAbstractComponent, decorators: [{
type: Directive
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Inject,
args: [PLATFORM_ID]
}] }, { type: i0.ElementRef }], 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