diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /shared/components/src/actions | |
init commit
Diffstat (limited to 'shared/components/src/actions')
| -rw-r--r-- | shared/components/src/actions/allow-drag.ts | 291 | ||||
| -rw-r--r-- | shared/components/src/actions/allow-drop.ts | 249 | ||||
| -rw-r--r-- | shared/components/src/actions/click-outside.ts | 18 | ||||
| -rw-r--r-- | shared/components/src/actions/focus-node-on-mount.ts | 5 | ||||
| -rw-r--r-- | shared/components/src/actions/focus-node.ts | 19 | ||||
| -rw-r--r-- | shared/components/src/actions/intersection-observer.ts | 100 | ||||
| -rw-r--r-- | shared/components/src/actions/list-keyboard-access.ts | 351 | ||||
| -rw-r--r-- | shared/components/src/actions/updateScrollAndWindowDependentVisuals.ts | 48 |
8 files changed, 1081 insertions, 0 deletions
diff --git a/shared/components/src/actions/allow-drag.ts b/shared/components/src/actions/allow-drag.ts new file mode 100644 index 0000000..7758979 --- /dev/null +++ b/shared/components/src/actions/allow-drag.ts @@ -0,0 +1,291 @@ +import type { ActionReturn } from 'svelte/action'; +import type { Readable } from 'svelte/store'; +import { writable } from 'svelte/store'; + +// Duplicate assignment from '~/components/DragImage.svelte' +const PRESET_CLASS = 'preset'; +const VISIBLE_CLASS = 'visible'; +const CONTAINER_CLASS = 'drag-image--container'; +const IMAGE_ATTR = 'data-drag-image-source'; +const BADGE_ATTR = 'data-drag-image-badge'; + +// resize fallback image when artwork is video or landscape +const ASPECT_RATIO_CLASS = 'aspect-landscape'; +const IS_DRAGGING_CLASS = 'is-dragging'; + +// Workaround for WebKit `effectAllowed` bug: https://bugs.webkit.org/show_bug.cgi?id=178058 +// This store points to the active drag handler, set on dragstart and unset on dragend. +// Only store subscription is exported to prevent modification outside this file. +const { set: setActiveDragHandler, subscribe } = + writable<DragHandler<any>>(null); +export const activeDragHandler: Readable<DragHandler<any>> = { subscribe }; + +/* + FOLLOW-UP WORK: + - it now adds and destroys the handler, and destroys and creates a new one on update. + We might want to keep track of any DragHandler that got created for an element and just update the existing instance. + rdar://98074771 (Onyx: DnD: Update allow-drag and allow-drop actions to support updates) + - Have the options dragEnabled be optional. If not passed in, it should be enabled. Just not when it's set to false. + We can't update that before the above changes are in. + - Use the logger instead of console.warn directly. + - Update DragImage clases and badge count from the DragImage component if possible +*/ + +/** + * Note: dragData needs to be JSON serializable, and no recursive structure + */ +export type DragOptions = { + dragEnabled: boolean; + dragData: unknown; // Needs to be JSON serializable. The DragData type is being set on initiating a new DragHandler<DragData> based on the passed in dragData + dragImage?: HTMLElement | string; + usePlainDragImage?: boolean; + isContainer?: boolean; + badgeCount?: number; + effectAllowed?: DataTransfer['effectAllowed']; +}; + +class DragHandler<DragData> { + private readonly element: HTMLElement; + private readonly options: DragOptions; + private readonly dragData: DragData; + private readonly dragImageContainer: HTMLElement; + private readonly fallbackImage: HTMLElement; + private dragImage: HTMLElement; + + constructor( + element: HTMLElement, + options: Omit<DragOptions, 'dragData'> & { dragData: DragData }, + ) { + this.element = element; + this.options = options; + this.dragData = options.dragData; + this.dragImageContainer = document.querySelector('[data-drag-image]'); + this.fallbackImage = document.querySelector('[data-fallback-image]'); + + if (!this.dragImageContainer) { + console.warn( + 'Use the <DragImage /> component to allow app specific drag images with fallback, badge and styling', + ); + } + + this.addEventListeners(); + this.setDraggable(); + } + + private setDraggable(): void { + this.element.draggable = true; + } + + private setDraggingClass = () => { + this.element.classList.add(IS_DRAGGING_CLASS); + }; + + private removeDraggingClass = () => { + this.element.classList.remove(IS_DRAGGING_CLASS); + }; + + private addEventListeners(): void { + // Create custom drag image before dragStart, because otherwise it might be empty + this.element.addEventListener('mousedown', this.createDragImage); + this.element.addEventListener('mouseup', this.resetDragImage); + + this.element.addEventListener('dragstart', this.onDragStart, { + capture: true, + }); + this.element.addEventListener('dragend', this.onDragEnd); + } + + public destroy(): void { + this.element.draggable = false; + this.element.style.setProperty('webkitUserDrag', 'auto'); + this.element.removeEventListener('mousedown', this.createDragImage); + this.element.removeEventListener('mouseup', this.resetDragImage); + this.element.removeEventListener('dragstart', this.onDragStart, { + capture: true, + }); + this.element.removeEventListener('dragend', this.onDragEnd); + } + + private onDragStart = (e: DragEvent): void => { + if (!this.dragData) { + // Interrupt the drag event as dragging should not be enabled on the element + e.preventDefault(); + return; + } + + // Prevent drag action on parent elements + e.stopPropagation(); + + if (this.dragImage) { + if (this.dragImage === this.dragImageContainer) { + // Make temporary visible to capture snapshot + this.dragImageContainer.classList.remove(PRESET_CLASS); + this.dragImageContainer.classList.add(VISIBLE_CLASS); + } + + const { clientWidth: imgWidth, clientHeight: imgHeight } = + this.dragImage; + e.dataTransfer.setDragImage( + this.dragImage, + imgWidth / 2, + imgHeight / 2, + ); + + // Remove the DOM drag image to not show up for the user. + // It needs a timeout to have it captured before it gets removed. + setTimeout(() => this.resetDragImage(), 1); + } + + e.dataTransfer.setData('text/plain', JSON.stringify(this.dragData)); + + // "Drop effect" controls what mouse cursor is shown during DnD operations + // See: https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed + e.dataTransfer.effectAllowed = this.getEffectAllowed(); + this.setDraggingClass(); + + setActiveDragHandler(this); + }; + + private onDragEnd = (): void => { + setActiveDragHandler(null); + this.resetDragImage(); + this.removeDraggingClass(); + }; + + private createDragImage = (): HTMLElement | null => { + this.resetDragImage(); + + const argsDragImage = this.options.dragImage; + let dragImage: HTMLElement; + + if (argsDragImage instanceof HTMLElement) { + dragImage = argsDragImage; + } else if (typeof argsDragImage === 'string') { + // Find the drag image based on the passed selector + dragImage = this.element.querySelector(argsDragImage); + } else { + // Use artwork by default + dragImage = this.element.querySelector( + '.artwork-component picture', + ); + } + + // Do not create a shallow copy inside our drag container with pre-set sizes. + // Can be used to either use the default browser behavior of using the element as drag image, + // or use another DOM element inside the draggable object without additional styling. + if (this.options.usePlainDragImage) { + // If no drag image set, use element (default browser drag behavior) + if (!argsDragImage) { + dragImage = this.element; + } + this.dragImage = dragImage; + return dragImage; + } + + // When no drag image container found (<DragImage /> component not rendered in the app), don't use a custom drag image + if (!this.dragImageContainer) return; + + // Container items should have a bigger drag image (albums, playlists) + if (this.options.isContainer) { + this.dragImageContainer.classList.add(CONTAINER_CLASS); + } + + // Clone image and add to drag image container + if (dragImage) { + const dragImageClone = dragImage.cloneNode(true); + this.dragImageContainer + .querySelector(`[${IMAGE_ATTR}]`) + .prepend(dragImageClone); + + // Prevents fallback image from overflowing video or landscaped artwork. + // In the Tracklist. See: .aspect-landscape class via DragImage.svelte + if (dragImage.offsetWidth / dragImage.offsetHeight !== 1) { + this.fallbackImage.classList.add(ASPECT_RATIO_CLASS); + } + } + + // Add a track count badge. Container items should always have track count, even if it's 1 (like a single-track-album). + if ( + this.badgeCount > 1 || + (this.options.isContainer && this.options.badgeCount > 0) + ) { + const badge = this.dragImageContainer.querySelector( + `[${BADGE_ATTR}]`, + ); + badge.classList.add(VISIBLE_CLASS); + badge.textContent = `${this.badgeCount}`; + } + + // Make visible for loading the image and capturing for drag image + this.dragImageContainer.classList.add(PRESET_CLASS); + this.dragImage = this.dragImageContainer; + }; + + /** + * DragImage is being set from the DragImage component: '@amp/web-app-components/src/components/DragImage.svelte'. + * We should find a better way of updating that rendered component instead of modifying the elements from here. + */ + private resetDragImage = (): void => { + this.dragImage = null; + const container = this.dragImageContainer; + container.classList.remove(PRESET_CLASS); + container.classList.remove(VISIBLE_CLASS); + container.classList.remove(CONTAINER_CLASS); + this.fallbackImage.classList.remove(ASPECT_RATIO_CLASS); + container.querySelector(`[${IMAGE_ATTR}]`).innerHTML = ''; + const badge = container.querySelector(`[${BADGE_ATTR}]`); + badge.classList.remove(VISIBLE_CLASS); + badge.innerHTML = ''; + }; + + private get badgeCount(): number { + return ( + this.options.badgeCount ?? + (Array.isArray(this.dragData) && this.dragData.length) + ); + } + + public getEffectAllowed(): DataTransfer['effectAllowed'] { + return this.options?.effectAllowed || 'copy'; + } +} + +/** + * Allow Drag action + * + * Usage: + * <div use:allow-drag={{ + * dragEnabled: true, + * dragData: yourDragData, + * isContainer: true, + * badgeCount: 4 + * }}></div> + */ +export function allowDrag( + target: HTMLElement, + options: DragOptions | false, +): ActionReturn<DragOptions> { + const enabled = options !== false && (options.dragEnabled ?? true); + let dragHandler; + + if (enabled && options.dragData) { + dragHandler = new DragHandler(target, options); + } + + return { + destroy: () => { + dragHandler?.destroy(); + }, + update: (updatedOptions: DragOptions) => { + // Hotfix for updated properties. Remove handlers with data and add new ones. + // TODO: rdar://98074771 (Onyx: DnD: Update allow-drag and allow-drop actions to support updates) + dragHandler?.destroy(); + + if (updatedOptions?.dragEnabled && updatedOptions?.dragData) { + dragHandler = new DragHandler(target, updatedOptions); + } + }, + }; +} + +export default allowDrag; diff --git a/shared/components/src/actions/allow-drop.ts b/shared/components/src/actions/allow-drop.ts new file mode 100644 index 0000000..231add4 --- /dev/null +++ b/shared/components/src/actions/allow-drop.ts @@ -0,0 +1,249 @@ +import type { ActionReturn } from 'svelte/action'; +import { get } from 'svelte/store'; +import { activeDragHandler } from '@amp/web-app-components/src/actions/allow-drag'; + +/* + FOLLOW-UP WORK: + - it now adds and destroys the handler, but doesn't have a update method. + We might want to keep track of any DropHandler that got created for an element and just update the existing instance. + rdar://98074771 (Onyx: DnD: Update allow-drag and allow-drop actions to support updates) +*/ + +const DROP_AREA_DATA_ATTR = 'data-drop-area'; +const DRAG_OVER_CLASS = 'is-drag-over'; + +export type DropOptions = { + dropEnabled: boolean; + onDrop: (details: DropData) => void; + targets?: + | [DropTarget] + | [DropTarget.Top, DropTarget.Bottom] + | [DropTarget.Left, DropTarget.Right]; + dropEffect?: DataTransfer['dropEffect']; +}; + +export type DropData = { + data: unknown; + dropTarget?: DropTarget; +}; + +export enum DropTarget { + Top = 'top', + Bottom = 'bottom', + Left = 'left', + Right = 'right', +} + +const DRAG_OVER_CLASSES = { + default: DRAG_OVER_CLASS, + [DropTarget.Top]: `${DRAG_OVER_CLASS}-${DropTarget.Top}`, + [DropTarget.Bottom]: `${DRAG_OVER_CLASS}-${DropTarget.Bottom}`, + [DropTarget.Left]: `${DRAG_OVER_CLASS}-${DropTarget.Left}`, + [DropTarget.Right]: `${DRAG_OVER_CLASS}-${DropTarget.Right}`, +}; + +class DropHandler { + private readonly element: HTMLElement; + private readonly options: DropOptions; + private enterTarget: HTMLElement; + private target: DropTarget; + private lastPosition: number; + + constructor(element: HTMLElement, options: DropOptions) { + this.element = element; + this.options = options; + + this.addEventListeners(); + } + + private addEventListeners = (): void => { + this.element.setAttribute(DROP_AREA_DATA_ATTR, ''); + this.element.addEventListener('dragenter', this.onDragEnter); + this.element.addEventListener('dragover', this.onDragOver); + this.element.addEventListener('dragleave', this.onDragLeave); + this.element.addEventListener('drop', this.onDrop); + }; + + private removeEventListeners = (): void => { + this.element.removeEventListener('dragenter', this.onDragEnter); + this.element.removeEventListener('dragover', this.onDragOver); + this.element.removeEventListener('dragleave', this.onDragLeave); + this.element.removeEventListener('drop', this.onDrop); + }; + + public destroy = (): void => { + this.resetState(); + this.element.removeAttribute(DROP_AREA_DATA_ATTR); + this.removeEventListeners(); + }; + + private resetState = (): void => { + this.enterTarget = null; + this.target = null; + this.lastPosition = null; + this.removeDragOverClasses(); + }; + + private removeDragOverClasses = (): void => { + Object.keys(DRAG_OVER_CLASSES).forEach((key) => { + this.element.classList.remove(DRAG_OVER_CLASSES[key]); + }); + }; + + private setDragOverClass = (targetName: DropTarget): void => { + const target = targetName || this.target; + const dragOverClass = + DRAG_OVER_CLASSES[target] || DRAG_OVER_CLASSES.default; + // add right target class if not yet present + if (!this.element.classList.contains(dragOverClass)) { + this.removeDragOverClasses(); // clear all target classes before switching target + this.element.classList.add(dragOverClass); + } + }; + + /** + * getLocationTarget: this function determines in what target region the user currently is + * + * @param e DragEvent + * @param threshold threshold for the target location switch zone + * @returns DropTarget + */ + private getLocationTarget = (e: DragEvent, threshold = 0): DropTarget => { + const { targets } = this.options; + + // Do not check on drag over region when it has no or one target + if (!targets || targets.length === 1) { + this.target = targets?.[0]; + return this.target; + } + + let position, size; + + // When using top - bottom targets + if (targets.join('-') === `${DropTarget.Top}-${DropTarget.Bottom}`) { + // offset to drop area, instead of target (which could be a child) + position = e.clientY - this.element.getBoundingClientRect().top; + size = this.element.offsetHeight; + } + // When using left - right targets + else if ( + targets.join('-') === `${DropTarget.Left}-${DropTarget.Right}` + ) { + // offset to drop area, instead of target (which could be a child) + position = e.clientX - this.element.getBoundingClientRect().left; + size = this.element.offsetWidth; + } + + if (position && size) { + if ( + !this.lastPosition || + Math.abs(position - this.lastPosition) > threshold + ) { + this.lastPosition = position; + this.target = position <= size / 2 ? targets[0] : targets[1]; + } + } + + return this.target; + }; + + private isCompatibleDropEffect(e: DragEvent) { + // Workaround for https://bugs.webkit.org/show_bug.cgi?id=178058 + // There is a longstanding WebKit bug where any value set by the user + // on `dataTransfer.effectAllowed` in the dragstart event is ignored + // and always returns "all". This means that we cannot trust the value + // that is set in the DragEvent. As a workaround, we store and check + // the active drag handler for the effectAllowed specified in the options. + // + // const { dropEffect, effectAllowed } = e.dataTransfer; + const { dropEffect } = e.dataTransfer; + const effectAllowed = get(activeDragHandler)?.getEffectAllowed(); + + return ( + effectAllowed === 'all' || + effectAllowed.toLowerCase().includes(dropEffect) + ); + } + + private onDragEnter = (e: DragEvent): void => { + e.dataTransfer.dropEffect = this.options.dropEffect || 'copy'; + + if (!this.isCompatibleDropEffect(e)) { + return; + } + + e.stopPropagation(); + + // Set enterTarget to cover entering child elements + this.enterTarget = e.target as HTMLElement; + this.setDragOverClass(this.getLocationTarget(e)); + }; + + private onDragOver = (e: DragEvent): void => { + e.dataTransfer.dropEffect = this.options.dropEffect || 'copy'; + + if (!this.isCompatibleDropEffect(e)) { + return; + } + + e.preventDefault(); // prevent the browser from default handling of the data to allow drop + e.stopPropagation(); // prevent setting classes on parent drop areas + this.setDragOverClass(this.getLocationTarget(e, 10)); + }; + + private onDragLeave = (e: Event): void => { + // Only set drag-over to false when it leaves the drop area. Not on entering childs + if (e.target === this.enterTarget) { + this.resetState(); + } + }; + + private onDrop = (e: DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); // Prevent drop action on parent elements + + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + const draggedData: DropData = { data }; + + if (this.target) { + draggedData.dropTarget = this.target; + } + + this.resetState(); + this.options.onDrop(draggedData); + }; +} + +/** + * Allow Drop action + * + * Usage: + * <div use:allow-drop={{ dropEnabled: true, onDrop: dropAction }}></div> + */ +export function allowDrop( + target: HTMLElement, + options: DropOptions, +): ActionReturn<DropOptions> { + let dropHandler; + + if (options?.dropEnabled && options?.onDrop) { + dropHandler = new DropHandler(target, options); + } + + return { + destroy: () => { + dropHandler?.destroy(); + }, + update: (updatedOptions: DropOptions) => { + // Hotfix for updated properties. Remove handlers with data and add new ones. + // TODO: rdar://98074771 (Onyx: DnD: Update allow-drag and allow-drop actions to support updates) + dropHandler?.destroy(); + + if (updatedOptions?.dropEnabled && updatedOptions?.onDrop) { + dropHandler = new DropHandler(target, updatedOptions); + } + }, + }; +} + +export default allowDrop; diff --git a/shared/components/src/actions/click-outside.ts b/shared/components/src/actions/click-outside.ts new file mode 100644 index 0000000..a9475c7 --- /dev/null +++ b/shared/components/src/actions/click-outside.ts @@ -0,0 +1,18 @@ +export default function clickOutside( + node: HTMLElement, + handler: (event: any) => void, +) { + const handleClick = (event) => { + if (!node.contains(event.target)) { + handler(event); + } + }; + + document.addEventListener('click', handleClick); + + return { + destroy() { + document.removeEventListener('click', handleClick); + }, + }; +} diff --git a/shared/components/src/actions/focus-node-on-mount.ts b/shared/components/src/actions/focus-node-on-mount.ts new file mode 100644 index 0000000..92bb4a9 --- /dev/null +++ b/shared/components/src/actions/focus-node-on-mount.ts @@ -0,0 +1,5 @@ +export function focusNodeOnMount(node: HTMLElement) { + // Wrapping in queueMicrotask ensures the node is attached to the + // DOM before attempting to focus. + queueMicrotask(() => node.focus()); +} diff --git a/shared/components/src/actions/focus-node.ts b/shared/components/src/actions/focus-node.ts new file mode 100644 index 0000000..907f584 --- /dev/null +++ b/shared/components/src/actions/focus-node.ts @@ -0,0 +1,19 @@ +export default function focusNode( + node: HTMLElement, + focusedIndex: number | null, +) { + const nodeIndex = Number(node.getAttribute('data-index')); + + // Handle the initial focus when applicable + if (nodeIndex === focusedIndex) { + node.focus(); + } + + return { + update(newFocusedIndex) { + if (nodeIndex === newFocusedIndex) { + node.focus(); + } + }, + }; +} diff --git a/shared/components/src/actions/intersection-observer.ts b/shared/components/src/actions/intersection-observer.ts new file mode 100644 index 0000000..cd22760 --- /dev/null +++ b/shared/components/src/actions/intersection-observer.ts @@ -0,0 +1,100 @@ +import { getRafQueue } from '@amp/web-app-components/src/utils/rafQueue'; +// TODO: rdar://91082022 (JMOTW: Performance - Refactor IntersectionObserver Admin Locally) +import IntersectionObserverAdmin from 'intersection-observer-admin'; + +// Threshold is how much of the target element is currently visible within the +// root's intersection ratio, as a value between 0.0 and 1.0. +// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/intersectionRatio +// +// Examples: +// 0 = a single visible pixel counts as the target being "visible" +// 1 = a single non-visible pixel counts as the target being "not visible"" +const DEFAULT_VIEWPORT_THRESHOLD = 0.6; + +// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver#properties +// Adding `callback` to the type since you can only pass an array or object into actions +type configObject = { + root?: Element | null; + rootMargin?: string; + threshold?: number; + callback?: Function; +}; + +let intersectionObserverAdmin; + +/** + * IntersectionObserver action to track when an element comes in to/goes out of the visible viewport. + * Useful for stopping animations of elements no longer visible, starting animations when + * they appear/reappear, applying/removing styles, etc. + * + * Callbacks will be called with a boolean depending on if the item is intersecting (true) or not (false). + * + * Utilizes Intersection Observer Admin (https://github.com/snewcomer/intersection-observer-admin) to allow + * the setup of a single Intersection Observer queue that handles observations in a way that allows each + * element to have it's own callback and IntersectionObserver configuration. + * + * @function intersectionObserver + * @param {Element} target Element to track (DOM element, Document, or null for top-level document viewport) + * @param {configObject} options callback function for handling viewport visiblity changes + * + * @example `<div use:intersectionObserver={{ callback: handleIntersectionUpdate }}></div>` + * @example `<div use:intersectionObserver={{ + * callback: handleIntersectionUpdate, + * root: document.querySelector('some-element') + * }}></div>` + * @example `<div use:intersectionObserver={{ + * callback: handleIntersectionUpdate, + * root: document.querySelector('some-element'), + * threshold: 1 + * }}></div>` + * @example `<div use:intersectionObserver={{ + * callback: handleIntersectionUpdate, + * root: document.querySelector('some-element'), + * rootMargin: '0px 0px 0px 0px', + * threshold: 1 + * }}></div>` + */ +export function intersectionObserver( + target: Element, + options: configObject = {}, +): { destroy: () => void } { + if (!('IntersectionObserver' in window)) return; + + if (!options.callback) { + console.warn( + 'Use of intersectionObserver action requires passing in a callback function', + ); + return; + } + + const rafQueue = getRafQueue(); + const customCallback = options.callback; + + // Clone options to manipulate object without side effects + // Assign initial default threshold, overridden by any settings in `options` + const optionsObj = Object.assign( + { threshold: DEFAULT_VIEWPORT_THRESHOLD }, + options, + ); + delete optionsObj.callback; + + const callback = (ioEntry) => { + rafQueue.add(() => customCallback(ioEntry.isIntersecting)); + }; + + if (!intersectionObserverAdmin) { + intersectionObserverAdmin = new IntersectionObserverAdmin(); + } + + // Add callbacks that will be called when observer detects entering and leaving viewport + intersectionObserverAdmin.addEnterCallback(target, callback); + intersectionObserverAdmin.addExitCallback(target, callback); + + intersectionObserverAdmin.observe(target, optionsObj); + + return { + destroy() { + intersectionObserverAdmin.unobserve(target, optionsObj); + }, + }; +} diff --git a/shared/components/src/actions/list-keyboard-access.ts b/shared/components/src/actions/list-keyboard-access.ts new file mode 100644 index 0000000..7ac5819 --- /dev/null +++ b/shared/components/src/actions/list-keyboard-access.ts @@ -0,0 +1,351 @@ +const NAVIGATION_KEY_NAMES = ['ArrowDown', 'ArrowUp']; +const INTERACTABLE_NODE_NAMES = ['A', 'BUTTON']; +export type configObject = { + listItemClassNames: string; + isRoving?: boolean; + listGroupElement?: HTMLElement; + syncInteractivityWithVisibility?: boolean; +}; + +type configParams = configObject & { targetElement: HTMLElement }; + +/** + * A construct that manages keyboard navigation as it relates to lists. + * @class + */ + +class ListKeyboardAccess { + private listItemClassNames: Array<string>; + private listParentElement: HTMLElement; + private boundFocusInHandler: EventListener; + private boundKeyDownHandler: EventListener; + private listGroupElement: HTMLElement | undefined; + // a current index based on an ancestor parent i.e. `listGroupElement`. + private currentRootIndex: number = -1; + // a current index based on an immediate list parent i.e. `listParentElement`. + private currentIndex: number = -1; + private isRoving: boolean = false; + private syncInteractivityWithVisibility: boolean | undefined; + private intersectionObserver: IntersectionObserver | undefined; + + static isWindowEventBound: boolean = false; + + constructor(options: configParams) { + const { + listGroupElement, + targetElement, + syncInteractivityWithVisibility, + } = options; + this.listParentElement = targetElement; + this.listGroupElement = listGroupElement; + this.isRoving = (options.isRoving ?? false) && !!this.listGroupElement; + this.syncInteractivityWithVisibility = syncInteractivityWithVisibility; + + // converting a string list into an array of CSS class names (note: not selectors). + this.listItemClassNames = options.listItemClassNames + ?.split(',') + .map((className) => className.trim()); + // Attempting to only bind this event once for the purpose of list navigation. + if (!ListKeyboardAccess.isWindowEventBound) { + window.addEventListener( + 'keydown', + ListKeyboardAccess.windowKeyUpHandler, + ); + ListKeyboardAccess.isWindowEventBound = true; + } + + if (this.listItemClassNames?.join('').length) { + this.boundFocusInHandler = this.focusInHandler.bind(this); + this.boundKeyDownHandler = this.keyDownHandler.bind(this); + + this.listParentElement.addEventListener( + 'focusin', + this.boundFocusInHandler, + { + capture: true, + }, + ); + this.listParentElement.addEventListener( + 'keydown', + this.boundKeyDownHandler, + ); + } else { + throw Error('ListKeyboardAccess requires listItemClassNames'); + } + + if (this.syncInteractivityWithVisibility) { + // Create the observer + this.intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + setItemInteractivity( + entry.target as HTMLElement, + entry.isIntersecting, + ); + }); + }, + { + root: targetElement, + rootMargin: '0px', + threshold: 0.5, + }, + ); + + const listItems = this.getListItems(); + for (let i = 0; i < listItems.length; i++) { + this.intersectionObserver.observe(listItems[i]); + } + } + } + + destroy() { + if (ListKeyboardAccess.isWindowEventBound) { + window.removeEventListener( + 'keydown', + ListKeyboardAccess.windowKeyUpHandler, + ); + ListKeyboardAccess.isWindowEventBound = false; + } + + this.listParentElement?.removeEventListener( + 'focusin', + this.boundFocusInHandler, + { + capture: true, + }, + ); + + this.listParentElement?.removeEventListener( + 'keydown', + this.boundKeyDownHandler, + ); + + this.intersectionObserver?.disconnect(); + } + + private getListItems( + fromlistGroupElement: boolean = false, + ): Array<HTMLElement> { + const { listGroupElement, listParentElement } = this; + const root = + fromlistGroupElement && listGroupElement + ? listGroupElement + : listParentElement; + const selectors = getSelectorsFromCSSClassNames( + this.listItemClassNames.join(','), + ); + return Array.from(root.querySelectorAll(selectors)); + } + + private focusInHandler(event: any) { + const currentListItem = this.findListItem(event.target); + const listItems = this.getListItems(); + // bail if no list items or currentListItem + if (!listItems.length || !currentListItem) return; + this.currentIndex = listItems.indexOf(currentListItem); + + this.currentRootIndex = this.getListItems(this.isRoving)?.indexOf( + currentListItem, + ); + + if (this.currentIndex >= 0 && this.isRoving) { + for (let i = 0; i < listItems.length; i++) { + setTabFocusable(listItems[i], i === this.currentIndex); + } + } + } + + private keyDownHandler(event: any) { + if ( + !NAVIGATION_KEY_NAMES.includes(event.key) || + this.currentIndex < 0 + ) { + return; + } + const currentIndex = this.isRoving + ? this.currentRootIndex + : this.currentIndex; + const listItems = this.getListItems(this.isRoving); + + let nextIndex = + event.key === 'ArrowUp' + ? Math.max(0, currentIndex - 1) + : Math.min(currentIndex + 1, listItems.length - 1); + + focusVisibleItemByIndex(nextIndex, currentIndex, listItems); + } + + /** + * A helper method to find the closest focusable list item. + * @param sourceElement origin of traversal + * @returns HTMLElement | null + */ + private findListItem(source: HTMLElement | null): HTMLElement | null { + if (!source || !this.listItemClassNames?.length) return null; + + const selector = this.listItemClassNames.map((c) => `.${c}`).join(','); + const hit = source.closest(selector) as HTMLElement | null; + if (hit) return hit; + + const parent = source.parentElement; + if (!parent) return null; + + // BFS over siblings and their descendants + const q: Element[] = Array.from(parent.children); + const checked = new Set<Element>([parent]); + for (let i = 0; i < q.length; i++) { + const el = q[i] as HTMLElement; + if (checked.has(el)) continue; + checked.add(el); + + if (el.matches(selector)) return el; + // enqueue children + for (const child of Array.from(el.children)) { + if (!checked.has(child)) q.push(child); + } + } + return null; + } + + /** + * Event handler for the window to stop scrolling the page when users use the arrow keys. + * @param event + */ + static windowKeyUpHandler(event: any) { + if (NAVIGATION_KEY_NAMES.includes(event.key)) { + event.preventDefault(); + } + } +} + +function focusVisibleItemByIndex( + index: number, + targetIndex: number, + listItems: Array<HTMLElement>, +) { + const direction = index - targetIndex > 0 ? 1 : -1; + const listItem = listItems[index]; + if (!listItem) { + return; + } + // Sometimes the list item itself is visible, but the parent + // is not--like the search button in the nav bar. + // Check visibility for the element and its parent before assigning focus. + if (isItemVisible(listItem) && isItemVisible(listItem.parentElement)) { + listItems[index].focus(); + } else { + focusVisibleItemByIndex(index + direction, targetIndex, listItems); + } +} + +function isItemVisible(element: HTMLElement | null): boolean { + if (element === null) return false; + const { display, visibility, opacity } = window.getComputedStyle(element); + return display !== 'none' && visibility !== 'hidden' && opacity !== '0'; +} + +function getSelectorsFromCSSClassNames(classes: string): string { + if (!classes) return ''; + return classes + .split(',') + .map((name) => `.${name.trim()}`) + .join(','); +} + +/** + * sets tabindex for an element following W3C Web standards. + * @param element HTMLElement + * @param isTabFocusable boolean "tab-focusable" refers to whether or not an element is focusable using the Tab key. + */ +export function setTabFocusable(element: HTMLElement, isTabFocusable: boolean) { + if (INTERACTABLE_NODE_NAMES.includes(element.nodeName)) { + const isAnchor = element.nodeName === 'A'; + if (isTabFocusable) { + element.removeAttribute(isAnchor ? 'tabindex' : 'disabled'); + } else { + const attribtuesToSet: [string, string] = isAnchor + ? ['tabindex', '-1'] + : ['disabled', 'true']; + element.setAttribute(...attribtuesToSet); + } + } else { + element.setAttribute('tabindex', isTabFocusable ? '0' : '-1'); + } +} + +export function setItemInteractivity( + shelfItemElement: HTMLElement, + isShelfItemVisible: boolean, +) { + if ( + INTERACTABLE_NODE_NAMES.includes(shelfItemElement.nodeName) || + shelfItemElement.getAttribute('tabindex') + ) { + // Handles the shelf item + setTabFocusable(shelfItemElement as HTMLElement, isShelfItemVisible); + } + + if (isShelfItemVisible) { + shelfItemElement.removeAttribute('aria-hidden'); + } else { + shelfItemElement.setAttribute('aria-hidden', 'true'); + } + + // handles the children in the item + const selectors: string = INTERACTABLE_NODE_NAMES.map((nodeName) => + nodeName.toLowerCase(), + ).join(','); + const interactiveContent: Array<HTMLAnchorElement | HTMLButtonElement> = + Array.from(shelfItemElement.querySelectorAll(selectors)); + for (let el of interactiveContent) { + setTabFocusable(el, isShelfItemVisible); + } +} + +/** + * set up mutation observer to ensure tab-focusablility is set appropriately based on the list item's focusability. + * @param listItemNode + * @param interactableTargets + * @returns + */ +export function initListItemObserver( + listItemNode: HTMLElement, + interactableTargets: Array<HTMLElement>, +): MutationObserver { + const observer = new MutationObserver((mutationsList) => { + let tabindex: number; + for (let mutation of mutationsList) { + if (mutation.type === 'attributes' && interactableTargets.length) { + for (let i = 0; i < interactableTargets.length; i++) { + tabindex = Number( + (mutation.target as HTMLElement).getAttribute( + 'tabindex', + ), + ); + setTabFocusable(interactableTargets[i], tabindex >= 0); + } + } + } + }); + if (listItemNode) { + observer.observe(listItemNode, { attributes: true }); + } + return observer; +} + +export function listKeyboardAccess( + targetElement: HTMLElement, + options: configObject = { listItemClassNames: '' }, +) { + const listKeyboardAXInstance = new ListKeyboardAccess({ + targetElement, + ...options, + }); + return { + destroy() { + listKeyboardAXInstance.destroy(); + }, + }; +} + +export default listKeyboardAccess; diff --git a/shared/components/src/actions/updateScrollAndWindowDependentVisuals.ts b/shared/components/src/actions/updateScrollAndWindowDependentVisuals.ts new file mode 100644 index 0000000..b934e37 --- /dev/null +++ b/shared/components/src/actions/updateScrollAndWindowDependentVisuals.ts @@ -0,0 +1,48 @@ +import { debounce } from '@amp/web-app-components/src/utils/debounce'; +import { throttle } from '@amp/web-app-components/src/utils/throttle'; +/** + * Dynamically change header and bottom gradient style when scrolling within a modal, and on window resize + */ +export function updateScrollAndWindowDependentVisuals(node) { + let animationRequest; + const handleScroll = () => { + // Get scroll details + const { scrollHeight, scrollTop, offsetHeight } = node; + const maxScroll = scrollHeight - offsetHeight; + + // Calculate whether content is scrolled + const contentIsScrolling = scrollTop > 1; + + // Calculate if bottom gradient should be hidden + const scrollingNotPossible = maxScroll === 0; + const pastMaxScroll = scrollTop >= maxScroll; + const hideGradient = scrollingNotPossible || pastMaxScroll; + + if (animationRequest) { + window.cancelAnimationFrame(animationRequest); + } + + animationRequest = window.requestAnimationFrame(() => + node.dispatchEvent( + new CustomEvent('scrollStatus', { + detail: { contentIsScrolling, hideGradient }, + }), + ), + ); + }; + + const onResize = throttle(handleScroll, 250); + const onScroll = debounce(handleScroll, 50); + node.addEventListener('scroll', onScroll, { capture: true, passive: true }); + window.addEventListener('resize', onResize); + + return { + destroy() { + node.removeEventListener('scroll', onScroll, { capture: true }); + window.removeEventListener('resize', onResize); + if (animationRequest) { + window.cancelAnimationFrame(animationRequest); + } + }, + }; +} |
