summaryrefslogtreecommitdiff
path: root/shared/components/src/actions
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /shared/components/src/actions
init commit
Diffstat (limited to 'shared/components/src/actions')
-rw-r--r--shared/components/src/actions/allow-drag.ts291
-rw-r--r--shared/components/src/actions/allow-drop.ts249
-rw-r--r--shared/components/src/actions/click-outside.ts18
-rw-r--r--shared/components/src/actions/focus-node-on-mount.ts5
-rw-r--r--shared/components/src/actions/focus-node.ts19
-rw-r--r--shared/components/src/actions/intersection-observer.ts100
-rw-r--r--shared/components/src/actions/list-keyboard-access.ts351
-rw-r--r--shared/components/src/actions/updateScrollAndWindowDependentVisuals.ts48
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);
+ }
+ },
+ };
+}