Skip to content

Commit

Permalink
fix(paginator): revert naive focus management changes in favor of def…
Browse files Browse the repository at this point in the history
…ault browser implementation when focused elements are disabled (#468)
  • Loading branch information
DRiFTy17 authored Feb 15, 2024
1 parent 72efc06 commit 96a9b85
Show file tree
Hide file tree
Showing 15 changed files with 245 additions and 231 deletions.
26 changes: 20 additions & 6 deletions src/lib/button-area/button-area-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { addClass, getShadowElement, removeClass, toggleClass } from '@tylertech/forge-core';

import { BaseAdapter, IBaseAdapter, userInteractionListener } from '../core';
import { BaseAdapter, IBaseAdapter, createUserInteractionListener } from '../core';
import { ForgeRipple, ForgeRippleAdapter, ForgeRippleCapableSurface, ForgeRippleFoundation } from '../ripple';
import { IButtonAreaComponent } from './button-area';
import { BUTTON_AREA_CONSTANTS } from './button-area-constants';

export interface IButtonAreaAdapter extends IBaseAdapter {
destroy(): void;
setDisabled(value: boolean): void;
addListener(type: string, listener: (event: Event) => void): void;
removeListener(type: string, listener: (event: Event) => void): void;
Expand All @@ -25,6 +26,7 @@ export class ButtonAreaAdapter extends BaseAdapter<IButtonAreaComponent> impleme
private _buttonElement?: HTMLButtonElement;
private _rippleInstance?: ForgeRipple;
private _buttonObserver?: MutationObserver;
private _destroyUserInteractionListener: (() => void) | undefined;

constructor(component: IButtonAreaComponent) {
super(component);
Expand All @@ -44,6 +46,13 @@ export class ButtonAreaAdapter extends BaseAdapter<IButtonAreaComponent> impleme
return this.buttonIsDisabled();
}

public destroy(): void {
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
this._destroyUserInteractionListener = undefined;
}
}

public setDisabled(value: boolean): void {
toggleClass(this._rootElement, value, BUTTON_AREA_CONSTANTS.classes.DISABLED);
this._buttonElement?.toggleAttribute(BUTTON_AREA_CONSTANTS.attributes.DISABLED, value);
Expand Down Expand Up @@ -97,7 +106,15 @@ export class ButtonAreaAdapter extends BaseAdapter<IButtonAreaComponent> impleme

public async createRipple(): Promise<void> {
if (!this._rippleInstance) {
const type = await this._userInteractionListener();
const { userInteraction, destroy } = await createUserInteractionListener(this._rootElement);
this._destroyUserInteractionListener = destroy;
const { type } = await userInteraction;
this._destroyUserInteractionListener = undefined;

if (!this.isConnected) {
return;
}

if (!this._rippleInstance) {
const adapter: ForgeRippleAdapter = {
...ForgeRipple.createAdapter(this),
Expand All @@ -122,6 +139,7 @@ export class ButtonAreaAdapter extends BaseAdapter<IButtonAreaComponent> impleme
removeClass: (className) => removeClass(className, this._rootElement),
updateCssVariable: (varName, value) => this._rootElement.style.setProperty(varName, value)
};

this._rippleInstance = new ForgeRipple(this._rootElement, new ForgeRippleFoundation(adapter));
if (type === 'focusin') {
this._rippleInstance.handleFocus();
Expand All @@ -133,10 +151,6 @@ export class ButtonAreaAdapter extends BaseAdapter<IButtonAreaComponent> impleme
}
}

private _userInteractionListener(): ReturnType<typeof userInteractionListener> {
return userInteractionListener(this._rootElement);
}

private _isRootEvent(evtType: string): boolean {
return ['touchstart', 'pointerdown', 'mousedown'].includes(evtType);
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/button-area/button-area-foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class ButtonAreaFoundation implements IButtonAreaFoundation {
}

public disconnect(): void {
this._adapter.destroy();
this._adapter.removeListener('click', this._clickListener);
this._adapter.removeSlotChangeListener(this._slotListener);
this._adapter.stopButtonObserver();
Expand Down
30 changes: 23 additions & 7 deletions src/lib/button/button.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CustomElement, ensureChildren, toggleAttribute } from '@tylertech/forge-core';
import { BaseComponent, IBaseComponent } from '../core/base/base-component';
import { ALLOWED_CHILDREN, BUTTON_CONSTANTS } from './button-constants';
import { userInteractionListener } from '../core/utils/utils';
import { createUserInteractionListener } from '../core/utils/utils';
import { ForgeRipple } from '../ripple';

export interface IButtonComponent extends IBaseComponent {
Expand Down Expand Up @@ -32,6 +32,7 @@ export class ButtonComponent extends BaseComponent implements IButtonComponent {
private _type: string;
private _mutationObserver: MutationObserver;
private _buttonAttrMutationObserver: MutationObserver;
private _destroyUserInteractionListener: (() => void) | undefined;

constructor() {
super();
Expand All @@ -54,6 +55,11 @@ export class ButtonComponent extends BaseComponent implements IButtonComponent {
}

public disconnectedCallback(): void {
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
this._destroyUserInteractionListener = undefined;
}

if (this._rippleInstance) {
this._rippleInstance.destroy();
}
Expand Down Expand Up @@ -128,12 +134,22 @@ export class ButtonComponent extends BaseComponent implements IButtonComponent {
}

private async _deferRippleInitialization(): Promise<void> {
const type = await userInteractionListener(this._buttonElement);
if (!this._rippleInstance) {
this._rippleInstance = this._createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
}

const { userInteraction, destroy } = createUserInteractionListener(this._buttonElement);
this._destroyUserInteractionListener = destroy;
const { type } = await userInteraction;
this._destroyUserInteractionListener = undefined;

if (!this.isConnected) {
return;
}

this._rippleInstance = this._createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
}

Expand Down
30 changes: 23 additions & 7 deletions src/lib/checkbox/checkbox-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { addClass, getShadowElement, removeClass } from '@tylertech/forge-core';
import { userInteractionListener } from '../core';
import { createUserInteractionListener } from '../core';
import { BaseAdapter, IBaseAdapter } from '../core/base/base-adapter';
import { ForgeRipple, ForgeRippleAdapter, ForgeRippleCapableSurface, ForgeRippleFoundation } from '../ripple';
import { ICheckboxComponent } from './checkbox';
Expand Down Expand Up @@ -38,6 +38,7 @@ export class CheckboxAdapter extends BaseAdapter<ICheckboxComponent> implements
private _inputBlurHandler: () => void;
private _inputMutationObserver: MutationObserver;
private _rippleInstance: ForgeRipple | undefined;
private _destroyUserInteractionListener: (() => void) | undefined;

constructor(component: ICheckboxComponent) {
super(component);
Expand All @@ -63,12 +64,22 @@ export class CheckboxAdapter extends BaseAdapter<ICheckboxComponent> implements
}

private async _deferRippleInitialization(): Promise<void> {
const type = await userInteractionListener(this._rootElement);
if (!this._rippleInstance) {
this._rippleInstance = this._createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
}

const { userInteraction, destroy } = createUserInteractionListener(this._rootElement);
this._destroyUserInteractionListener = destroy;
const { type } = await userInteraction;
this._destroyUserInteractionListener = undefined;

if (!this.isConnected) {
return;
}

this._rippleInstance = this._createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
}

Expand Down Expand Up @@ -112,6 +123,11 @@ export class CheckboxAdapter extends BaseAdapter<ICheckboxComponent> implements
}

public disconnect(): void {
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
this._destroyUserInteractionListener = undefined;
}

if (this._labelElement) {
this._labelElement.removeAttribute(CHECKBOX_CONSTANTS.attributes.SLOT);
}
Expand Down
31 changes: 25 additions & 6 deletions src/lib/core/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,38 @@ export function highlightTextHTML(label: string, highlightText: string): HTMLEle
* @param capture Whether to use capturing listeners or not.
* @returns A `Promise` that will be resolved when either of the listeners has executed.
*/
export function userInteractionListener(element: HTMLElement, { capture = true, pointerenter = true, focusin = true } = {}): Promise<'pointerenter' | 'focusin'> {
return new Promise<'pointerenter' | 'focusin'>(resolve => {
export function createUserInteractionListener(element: HTMLElement, { capture = true, pointerenter = true, focusin = true } = {}): { userInteraction: Promise<Event>; destroy: () => void } {
let destroyFn: () => void;
const destroy: () => void = () => {
if (typeof destroyFn === 'function') {
destroyFn();
}
};

const userInteraction = new Promise<Event>(resolve => {
const listenerOpts: EventListenerOptions & { once: boolean } = { once: true, capture };

const handlePointerenter = (): void => {
const handlePointerenter = (evt: Event): void => {
if (focusin) {
element.removeEventListener('focusin', handleFocusin, listenerOpts);
}
resolve('pointerenter');
resolve(evt);
};

const handleFocusin = (): void => {
const handleFocusin = (evt: Event): void => {
if (pointerenter) {
element.removeEventListener('pointerenter', handlePointerenter, listenerOpts);
}
resolve(evt);
};

destroyFn = (): void => {
if (pointerenter) {
element.removeEventListener('pointerenter', handlePointerenter, listenerOpts);
}
resolve('focusin');
if (focusin) {
element.removeEventListener('focusin', handleFocusin, listenerOpts);
}
};

if (pointerenter) {
Expand All @@ -60,6 +76,9 @@ export function userInteractionListener(element: HTMLElement, { capture = true,
element.addEventListener('focusin', handleFocusin, listenerOpts);
}
});


return { userInteraction, destroy };
}

/**
Expand Down
45 changes: 38 additions & 7 deletions src/lib/icon-button/icon-button.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { coerceBoolean, coerceNumber, CustomElement, emitEvent, ensureChild, toggleClass } from '@tylertech/forge-core';
import { BaseComponent, IBaseComponent } from '../core/base/base-component';
import { ForgeRipple } from '../ripple';
import { userInteractionListener } from '../core/utils';
import { createUserInteractionListener } from '../core/utils';
import { ICON_BUTTON_CONSTANTS } from './icon-button-constants';

export interface IIconButtonComponent extends IBaseComponent {
Expand Down Expand Up @@ -47,6 +47,8 @@ export class IconButtonComponent extends BaseComponent implements IIconButtonCom
private _dense = false;
private _densityLevel = 5;
private _toggleHandler: (event: Event) => void;
private _buttonAttrMutationObserver: MutationObserver;
private _destroyUserInteractionListener: (() => void) | undefined;

constructor() {
super();
Expand All @@ -61,6 +63,15 @@ export class IconButtonComponent extends BaseComponent implements IIconButtonCom
}

public disconnectedCallback(): void {
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
this._destroyUserInteractionListener = undefined;
}

if (this._buttonAttrMutationObserver) {
this._buttonAttrMutationObserver.disconnect();
}

if (this._rippleInstance) {
this._rippleInstance.destroy();
}
Expand Down Expand Up @@ -153,6 +164,16 @@ export class IconButtonComponent extends BaseComponent implements IIconButtonCom
emitEvent(this, ICON_BUTTON_CONSTANTS.events.CHANGE, this._isOn, true);
};

// Watch attributes on the `<button>` element
this._buttonAttrMutationObserver = new MutationObserver(mutationList => {
if (mutationList.some(mutation => mutation.attributeName === 'disabled')) {
if (this._buttonElement.hasAttribute('disabled')) {
this._rippleInstance?.handleBlur();
}
}
});
this._buttonAttrMutationObserver.observe(this._buttonElement, { attributes: true, attributeFilter: ['disabled'] });

if (this._toggle) {
this._initializeToggle();
}
Expand All @@ -162,12 +183,22 @@ export class IconButtonComponent extends BaseComponent implements IIconButtonCom
}

private async _deferRippleInitialization(): Promise<void> {
const type = await userInteractionListener(this._buttonElement);
if (!this._rippleInstance) {
this._rippleInstance = this._createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
}

const { userInteraction, destroy } = createUserInteractionListener(this._buttonElement);
this._destroyUserInteractionListener = destroy;
const { type } = await userInteraction;
this._destroyUserInteractionListener = undefined;

if (!this.isConnected) {
return;
}

this._rippleInstance = this._createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/lib/list/list-item/list-item-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { addClass, getShadowElement, removeClass, requireParent, isDeepEqual, toggleClass } from '@tylertech/forge-core';
import { BaseAdapter, IBaseAdapter } from '../../core/base/base-adapter';
import { userInteractionListener } from '../../core/utils';
import { createUserInteractionListener } from '../../core/utils';
import { IListComponent } from '../list/list';
import { LIST_CONSTANTS } from '../list/list-constants';
import { IListItemComponent } from './list-item';
Expand Down Expand Up @@ -28,7 +28,7 @@ export interface IListItemAdapter extends IBaseAdapter {
setIndented(indented: boolean): void;
setWrap(value: boolean): void;
trySelect(value: unknown): boolean | null;
userInteractionListener(): ReturnType<typeof userInteractionListener>;
createUserInteractionListener(): ReturnType<typeof createUserInteractionListener>;
}

export class ListItemAdapter extends BaseAdapter<IListItemComponent> implements IListItemAdapter {
Expand Down Expand Up @@ -224,7 +224,7 @@ export class ListItemAdapter extends BaseAdapter<IListItemComponent> implements
return isSelected;
}

public userInteractionListener(): ReturnType<typeof userInteractionListener> {
return userInteractionListener(this._listItemElement);
public createUserInteractionListener(): ReturnType<typeof createUserInteractionListener> {
return createUserInteractionListener(this._listItemElement);
}
}
18 changes: 16 additions & 2 deletions src/lib/list/list-item/list-item-foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class ListItemFoundation implements IListItemFoundation {
private _clickListener: (evt: MouseEvent) => void;
private _mouseDownListener: (evt: MouseEvent) => void;
private _keydownListener: (evt: KeyboardEvent) => void;
private _destroyUserInteractionListener: (() => void) | undefined;

constructor(private _adapter: IListItemAdapter) {
this._clickListener = (evt: MouseEvent) => this._onClick(evt);
Expand Down Expand Up @@ -80,6 +81,11 @@ export class ListItemFoundation implements IListItemFoundation {
this._rippleInstance.destroy();
this._rippleInstance = undefined as any;
}

if (typeof this._destroyUserInteractionListener === 'function') {
this._destroyUserInteractionListener();
this._destroyUserInteractionListener = undefined;
}
}

private _onMouseDown(evt: MouseEvent): void {
Expand Down Expand Up @@ -344,8 +350,16 @@ export class ListItemFoundation implements IListItemFoundation {

private async _setRipple(): Promise<void> {
if (this._ripple && !this._static && !this._rippleInstance) {
const type = await this._adapter.userInteractionListener();
if (this._ripple && !this._static && !this._rippleInstance) { // need to re-check after await
const { userInteraction, destroy } = await this._adapter.createUserInteractionListener();
this._destroyUserInteractionListener = destroy;
const { type } = await userInteraction;
this._destroyUserInteractionListener = undefined;

if (!this._adapter.isConnected) {
return;
}

if (this._ripple && !this._static && !this._rippleInstance) {
this._rippleInstance = this._adapter.createRipple();
if (type === 'focusin') {
this._rippleInstance.handleFocus();
Expand Down
Loading

0 comments on commit 96a9b85

Please sign in to comment.