summaryrefslogtreecommitdiff
path: root/shared/components/src/utils
diff options
context:
space:
mode:
Diffstat (limited to 'shared/components/src/utils')
-rw-r--r--shared/components/src/utils/cookie.ts71
-rw-r--r--shared/components/src/utils/date.ts51
-rw-r--r--shared/components/src/utils/debounce.ts40
-rw-r--r--shared/components/src/utils/getMediaConditions.ts117
-rw-r--r--shared/components/src/utils/getStorefrontRoute.ts29
-rw-r--r--shared/components/src/utils/getUpdatedFocusedIndex.ts25
-rw-r--r--shared/components/src/utils/internal/locale/index.ts17
-rw-r--r--shared/components/src/utils/makeSafeTick.ts64
-rw-r--r--shared/components/src/utils/memoize.ts26
-rw-r--r--shared/components/src/utils/rafQueue.ts74
-rw-r--r--shared/components/src/utils/sanitize-html/browser.ts26
-rw-r--r--shared/components/src/utils/sanitize-html/common.ts176
-rw-r--r--shared/components/src/utils/sanitize.ts32
-rw-r--r--shared/components/src/utils/scrollByPolyfill.ts143
-rw-r--r--shared/components/src/utils/shelfAspectRatio.ts75
-rw-r--r--shared/components/src/utils/should-show-navigation-item.ts25
-rw-r--r--shared/components/src/utils/throttle.ts49
-rw-r--r--shared/components/src/utils/uniqueId.ts71
18 files changed, 1111 insertions, 0 deletions
diff --git a/shared/components/src/utils/cookie.ts b/shared/components/src/utils/cookie.ts
new file mode 100644
index 0000000..112733f
--- /dev/null
+++ b/shared/components/src/utils/cookie.ts
@@ -0,0 +1,71 @@
+export function getCookie(name: string): string | null {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+
+ const prefix = `${name}=`;
+ const cookie = document.cookie
+ .split(';')
+ .map((value) => value.trimStart())
+ .filter((value) => value.startsWith(prefix))[0];
+
+ if (!cookie) {
+ return null;
+ }
+
+ return cookie.substr(prefix.length);
+}
+
+export function setCookie(
+ name: string,
+ value: string,
+ domain: string,
+ expires = 0,
+ path = '/',
+): void {
+ if (typeof document === 'undefined') {
+ return undefined;
+ }
+
+ // Get any potential existing instances of this particular cookie
+ const existingCookie = getCookie(name);
+ let cookieValue = value;
+
+ if (existingCookie) {
+ // If exisitng cookie name does not include the value we are trying to set,
+ // then add it, otherwise use the existing cookie value
+ cookieValue = !existingCookie.includes(value)
+ ? `${existingCookie}+${value}`
+ : existingCookie;
+ }
+
+ let cookieString = `${name}=${cookieValue}; path=${path}; domain=${domain};`;
+
+ if (expires) {
+ const date = new Date();
+ date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000);
+
+ cookieString += ` expires=${date.toUTCString()};`;
+ }
+
+ document.cookie = cookieString;
+
+ // Returning undefined because of ESLint's "consistent-return" rule
+ return undefined;
+}
+
+export function clearCookie(name: string, domain: string, path = '/'): void {
+ if (typeof document === 'undefined') {
+ return undefined;
+ }
+
+ // Get any potential existing instances of this particular cookie
+ const existingCookie = getCookie(name);
+
+ if (existingCookie) {
+ // Set the cookie's expiration date to a past date
+ setCookie(name, '', domain, -1, path);
+ }
+
+ return undefined;
+}
diff --git a/shared/components/src/utils/date.ts b/shared/components/src/utils/date.ts
new file mode 100644
index 0000000..f128de7
--- /dev/null
+++ b/shared/components/src/utils/date.ts
@@ -0,0 +1,51 @@
+// Breaks duration down from milliseconds into hours/minutes/seconds
+export function getDurationParts(durationInMilliseconds: number): {
+ hours: number;
+ minutes: number;
+ seconds: number;
+} {
+ // convert ms to seconds
+ const durationInSeconds = Math.floor(durationInMilliseconds / 1000);
+ const duration = Math.round(durationInSeconds);
+
+ return {
+ hours: Math.floor(duration / 3600),
+ minutes: Math.floor(duration / 60) % 60,
+ seconds: duration % 60,
+ };
+}
+
+// returns normal numeric date in YYYY-MM-DD from a date string
+// AKA getNumericDateFromReleaseDate but renamed to be more generic
+//
+// ex: getNumericDateFromDateString('2024-04-15T08:41:03Z') => '2024-04-15'
+// getNumericDateFromDateString('15 April 2024 14:48 UTC') => '2024-04-15'
+export function getNumericDateFromDateString(
+ timestamp?: string,
+): string | undefined {
+ if (!timestamp) {
+ return undefined;
+ }
+
+ return new Date(timestamp).toISOString().split('T')?.[0];
+}
+
+// Utility to format ISO8601 Duration Strings from raw milliseconds (ex: PT2M42S).
+export function formatISODuration(durationInMilliseconds: number): string {
+ const { hours, minutes, seconds } = getDurationParts(
+ durationInMilliseconds,
+ );
+
+ if (!hours && !minutes && !seconds) {
+ return 'P0D';
+ }
+
+ return [
+ 'PT',
+ hours && `${hours}H`,
+ minutes && `${minutes}M`,
+ seconds && `${seconds}S`,
+ ]
+ .filter(Boolean)
+ .join('');
+}
diff --git a/shared/components/src/utils/debounce.ts b/shared/components/src/utils/debounce.ts
new file mode 100644
index 0000000..fcadbef
--- /dev/null
+++ b/shared/components/src/utils/debounce.ts
@@ -0,0 +1,40 @@
+/* eslint-disable import/prefer-default-export */
+
+/**
+ * @name debounce
+ * @description
+ * Creates a debounced function that delays invoking func until
+ * after delayMs milliseconds have elapsed since the last time the
+ * debounced function was invoked.
+ *
+ * @param delayMs - delay in milliseconds
+ * @param immediate - Specify invoking on the leading edge of the timeout
+ * (Defaults to trailing)
+ *
+ *(f: F): (...args: Parameters<F>) => void
+ */
+export function debounce<F extends (...args: any[]) => any>(
+ fn: F,
+ delayMs: number,
+ immediate = false,
+): (...args: Parameters<F>) => void {
+ let timerId;
+
+ return function debounced(...args) {
+ const shouldCallNow = immediate && !timerId;
+ clearTimeout(timerId);
+
+ if (shouldCallNow) {
+ fn.apply(this, args);
+ }
+
+ timerId = setTimeout(() => {
+ timerId = null;
+ if (!immediate) {
+ fn.apply(this, args);
+ }
+ }, delayMs);
+ };
+}
+
+export const DEFAULT_MOUSE_OVER_DELAY = 300;
diff --git a/shared/components/src/utils/getMediaConditions.ts b/shared/components/src/utils/getMediaConditions.ts
new file mode 100644
index 0000000..2d5028b
--- /dev/null
+++ b/shared/components/src/utils/getMediaConditions.ts
@@ -0,0 +1,117 @@
+import type { Breakpoints, Size } from '@amp/web-app-components/src/types';
+
+export type MediaConditions<T extends string | number | symbol = Size> = {
+ [key in T]?: string;
+};
+
+type BasicBreapoints<T extends string | number | symbol> = Record<T, number>;
+
+type BreakpointOptions = { offset?: number };
+
+// eslint-disable-next-line import/prefer-default-export
+export function getMediaConditions<T extends string | number | symbol = Size>(
+ breakpoints: Breakpoints<T>,
+ options?: BreakpointOptions,
+): MediaConditions<T> {
+ const viewportOrder = {
+ xsmall: 0,
+ small: 1,
+ medium: 2,
+ large: 3,
+ xlarge: 4,
+ };
+
+ const offset = options?.offset ?? 0;
+ const viewportSizes = Object.keys(breakpoints).sort(
+ (a, b) => viewportOrder[a] - viewportOrder[b],
+ ) as T[];
+
+ return viewportSizeToMediaConditions<T>(breakpoints, viewportSizes, offset);
+}
+
+function viewportSizeToMediaConditions<T extends string | number | symbol>(
+ breakpoints: Breakpoints<T>,
+ viewportSizes?: T[],
+ offset?: number,
+): MediaConditions<T> {
+ viewportSizes ||= Object.keys(breakpoints) as T[];
+ const queries: MediaConditions<T> = {};
+ viewportSizes.reduce((acc, viewport) => {
+ const { min, max } = {
+ min: undefined,
+ max: undefined,
+ ...breakpoints[viewport],
+ };
+
+ if (min && !max) {
+ acc[viewport] = `(min-width:${min + offset}px)`;
+ } else if (!min && max) {
+ acc[viewport] = `(max-width:${max + offset}px)`;
+ } else if (min && max) {
+ acc[viewport] = `(min-width:${min + offset}px) and (max-width:${
+ max + offset
+ }px)`;
+ }
+ return acc;
+ }, queries);
+ return queries;
+}
+
+/**
+ * Transforms a breakpoints object into media queries that match ranges between each breakpoint and the next.
+ *
+ * @param breakpoints - Object with breakpoint names as keys and pixel values as values
+ * @returns Object with breakpoint names as keys and media query strings as values
+ *
+ * @example
+ * const breakpoints = { XSM: 0, SM: 350, MD: 484, LG: 1000 };
+ * const mediaQueries = breakpointsToMediaQueries(breakpoints);
+ * // Returns:
+ * // {
+ * // XSM: '(max-width: 349px)',
+ * // SM: '(min-width: 350px) and (max-width: 483px)',
+ * // MD: '(min-width: 484px) and (max-width: 999px)',
+ * // LG: '(min-width: 1000px)'
+ * // }
+ */
+export function breakpointsToMediaQueries<T extends string>(
+ breakpoints: BasicBreapoints<T>,
+): MediaConditions<T> {
+ const entries = Object.entries(breakpoints) as [T, number][];
+ entries.sort(([, a], [_, b]) => a - b);
+ const transformedBreakpoints: Breakpoints<T> = {};
+
+ entries.forEach(([breakpointName, minWidth], index) => {
+ const isFirst = index === 0;
+ const isLast = index === entries.length - 1;
+ const nextBreakpointWidth = isLast ? null : entries[index + 1][1];
+
+ if (isFirst && minWidth === 0) {
+ // First breakpoint starting at 0: only max-width
+ if (nextBreakpointWidth !== null) {
+ transformedBreakpoints[breakpointName] = {
+ max: nextBreakpointWidth - 1,
+ };
+ } else {
+ // Edge case: only one breakpoint starting at 0
+ transformedBreakpoints[breakpointName] = { min: 0 };
+ }
+ } else if (isLast) {
+ // Last breakpoint: only min-width
+ transformedBreakpoints[breakpointName] = { min: minWidth };
+ } else {
+ // Middle breakpoints: min-width and max-width range
+ transformedBreakpoints[breakpointName] = {
+ min: minWidth,
+ max: nextBreakpointWidth! - 1,
+ };
+ }
+ });
+
+ const viewportSizes = entries.map(([breakpointName]) => breakpointName);
+ return viewportSizeToMediaConditions<T>(
+ transformedBreakpoints,
+ viewportSizes,
+ 0,
+ );
+}
diff --git a/shared/components/src/utils/getStorefrontRoute.ts b/shared/components/src/utils/getStorefrontRoute.ts
new file mode 100644
index 0000000..2aaaace
--- /dev/null
+++ b/shared/components/src/utils/getStorefrontRoute.ts
@@ -0,0 +1,29 @@
+/**
+ * Defines a route based on a given default route and
+ * otherwise falls back to the base storefront path
+ *
+ * @param defaultRoute - ie 'browse', 'listen-now', or empty string
+ * @param storefront - storefront id ie 'us'
+ * @param language - language tag ie 'en-US'
+ * @returns route - ie /us/browse?l=es-MX
+ */
+export function getStorefrontRoute(
+ defaultRoute: string,
+ storefront: string,
+ language?: string,
+): string {
+ let route;
+
+ if (defaultRoute === '') {
+ route = `/${storefront}`;
+ } else {
+ route = `/${storefront}/${defaultRoute}`;
+ }
+
+ // add optional language tag if that is passed in
+ if (language) {
+ route = `${route}?l=${language}`;
+ }
+
+ return route;
+}
diff --git a/shared/components/src/utils/getUpdatedFocusedIndex.ts b/shared/components/src/utils/getUpdatedFocusedIndex.ts
new file mode 100644
index 0000000..ca2c765
--- /dev/null
+++ b/shared/components/src/utils/getUpdatedFocusedIndex.ts
@@ -0,0 +1,25 @@
+export function getUpdatedFocusedIndex(
+ incrementAmount: number,
+ currentFocusedIndex: number | null,
+ numberOfItems: number,
+): number {
+ const potentialFocusedIndex = incrementAmount + currentFocusedIndex;
+
+ if (incrementAmount > 0) {
+ if (currentFocusedIndex === null) {
+ return 0;
+ } else {
+ return potentialFocusedIndex >= numberOfItems
+ ? 0
+ : potentialFocusedIndex;
+ }
+ } else {
+ if (currentFocusedIndex === null) {
+ return numberOfItems - 1;
+ } else {
+ return potentialFocusedIndex < 0
+ ? numberOfItems - 1
+ : potentialFocusedIndex;
+ }
+ }
+}
diff --git a/shared/components/src/utils/internal/locale/index.ts b/shared/components/src/utils/internal/locale/index.ts
new file mode 100644
index 0000000..e4165a9
--- /dev/null
+++ b/shared/components/src/utils/internal/locale/index.ts
@@ -0,0 +1,17 @@
+/* istanbul ignore file */
+
+//TODO rdar://93379311 (Solution for sharing context between app + shared components)
+import { getContext, setContext } from 'svelte';
+import type { Locale } from '@amp/web-app-components/src/types';
+
+const CONTEXT_NAME = 'shared:locale';
+
+// WARNING these signatures can change after rdar://93379311
+export function setLocale(context: Map<string, unknown>, locale: Locale) {
+ context.set(CONTEXT_NAME, locale);
+}
+
+// WARNING these signatures can change after rdar://93379311
+export function getLocale(): Locale {
+ return getContext(CONTEXT_NAME) as Locale | undefined;
+}
diff --git a/shared/components/src/utils/makeSafeTick.ts b/shared/components/src/utils/makeSafeTick.ts
new file mode 100644
index 0000000..f9ea8c2
--- /dev/null
+++ b/shared/components/src/utils/makeSafeTick.ts
@@ -0,0 +1,64 @@
+/* eslint-disable import/prefer-default-export */
+// eslint-disable-next-line no-restricted-imports
+import { tick as svelteTick, onDestroy } from 'svelte';
+
+// Unfortantely for TS to recognize that this can be awaited
+// we need to leave `Promise<void | never>` otherwise TS hints
+// will suggest removing the await.
+// See @remarks for reason to disable `then`
+type TickType = () => Omit<Promise<string>, 'then'> | Promise<void | never>;
+
+type SafeTickCallback = (tick: TickType) => Promise<void | never>;
+
+class DestroyedError extends Error {
+ constructor() {
+ super('component was destroyed before tick resolved.');
+ this.name = 'DestroyedError';
+ }
+}
+
+/**
+ * Provides a safer way to use svelte's tick helper.
+ *
+ * This prevents code that relies on tick() from running
+ * if the component is destroyed while the tick resolution
+ * is inflight.
+ *
+ * @remarks
+ * To avoid floating promises (promises with no return statements)
+ * it is safer to use the `async/await` syntax.
+ *
+ * If this is used with the `.then()` syntax without the promise
+ * being returned the DestroyedError will bubble up to sentry.
+ *
+ * @example
+ * ```ts
+ * const safeTick = makeSafeTick();
+ * onMount(async() => {
+ * await safeTick(async (tick) => {
+ * // Use tick normally
+ * await tick();
+ * // ...
+ * });
+ * });
+ * ```
+ */
+export const makeSafeTick = (): ((
+ callback: SafeTickCallback,
+) => Promise<void | never>) => {
+ let destroyed = false;
+ onDestroy(() => {
+ destroyed = true;
+ });
+
+ return async (callback) => {
+ try {
+ await callback(async () => {
+ await svelteTick();
+ if (destroyed) throw new DestroyedError();
+ });
+ } catch (e) {
+ if (!(e instanceof DestroyedError)) throw e;
+ }
+ };
+};
diff --git a/shared/components/src/utils/memoize.ts b/shared/components/src/utils/memoize.ts
new file mode 100644
index 0000000..a5e07ef
--- /dev/null
+++ b/shared/components/src/utils/memoize.ts
@@ -0,0 +1,26 @@
+// eslint-disable-next-line import/prefer-default-export
+export function memoize<T extends unknown[], S>(
+ fn: (...args: T) => S,
+ hashFn: (...args: unknown[]) => string = JSON.stringify,
+ entryLimit = 5,
+): (...args: T) => S {
+ const cache: Map<string, S> = new Map();
+
+ return (...args: T) => {
+ const value = hashFn(args);
+ if (cache.has(value)) {
+ return cache.get(value);
+ }
+
+ const returnedValue: S = fn.apply(this, args);
+
+ if (cache.size >= entryLimit) {
+ const iterator = cache.keys();
+ const firstValue = iterator.next().value;
+ // remove oldest value
+ cache.delete(firstValue);
+ }
+ cache.set(value, returnedValue);
+ return returnedValue;
+ };
+}
diff --git a/shared/components/src/utils/rafQueue.ts b/shared/components/src/utils/rafQueue.ts
new file mode 100644
index 0000000..a56d9a7
--- /dev/null
+++ b/shared/components/src/utils/rafQueue.ts
@@ -0,0 +1,74 @@
+/**
+ * @name RequestAnimationFrameLimiter
+ * @description
+ * allows for multiple callbacks to be called
+ * within a single RAF function.
+ * It also spreads long running tasks across multiple
+ * microtask to help keep the main thread free for user interactions
+ *
+ */
+export class RequestAnimationFrameLimiter {
+ private queue: Array<(timestamp?: number) => void>;
+ private RAF_FN_LIMIT_MS: number;
+ private requestId: number | null;
+ constructor() {
+ this.queue = [];
+ // ideal limit for scroll based animations: https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution#reduce_complexity_or_use_web_workers
+ this.RAF_FN_LIMIT_MS = 3;
+ this.requestId = null;
+ }
+
+ private flush(): void {
+ this.requestId =
+ this.queue.length === 0
+ ? null
+ : window.requestAnimationFrame((timestamp) => {
+ const start = window.performance.now();
+ let ellapsedTime = 0;
+ const { RAF_FN_LIMIT_MS } = this;
+ let count = 0;
+
+ while (
+ count < this.queue.length &&
+ ellapsedTime < RAF_FN_LIMIT_MS
+ ) {
+ let item = this.queue[count];
+ if (item) {
+ item(timestamp);
+ }
+ const finishTime = window.performance.now();
+
+ count = count + 1;
+ ellapsedTime = finishTime - start;
+ }
+ const newQueue = this.queue.slice(count);
+
+ this.queue = newQueue;
+ this.flush();
+ });
+ }
+ public add(callback: () => void): void {
+ this.queue.push(callback);
+ if (this.requestId === null) {
+ this.flush();
+ }
+ }
+}
+
+let raf: RequestAnimationFrameLimiter | ServerSafeRAFLimiter = null;
+
+type ServerSafeRAFLimiter = {
+ add: (callback: () => void) => void;
+};
+
+export const getRafQueue = () => {
+ if (typeof window === 'undefined') {
+ // SSR safe
+ raf = {
+ add: (callback: () => void) => callback(),
+ };
+ } else if (raf === null) {
+ raf = new RequestAnimationFrameLimiter();
+ }
+ return raf;
+};
diff --git a/shared/components/src/utils/sanitize-html/browser.ts b/shared/components/src/utils/sanitize-html/browser.ts
new file mode 100644
index 0000000..ad8b804
--- /dev/null
+++ b/shared/components/src/utils/sanitize-html/browser.ts
@@ -0,0 +1,26 @@
+// Browser ONLY logic. Must have the same exports as server.ts
+// See: docs/isomorphic-imports.md
+
+import { type SanitizeHtmlOptions, sanitizeDocument } from './common';
+
+export { type SanitizeHtmlOptions, DEFAULT_SAFE_TAGS } from './common';
+
+// Shared DOMParser instance (avoids creating a new one for each sanitization)
+let parser = null;
+
+export function sanitizeHtml(
+ input: string,
+ options: SanitizeHtmlOptions = {},
+): string {
+ if (!input) {
+ return input;
+ }
+
+ if (!parser) {
+ parser = new DOMParser();
+ }
+
+ const unsafeDocument = parser.parseFromString(`${input}`, 'text/html');
+ const unsafeNode = unsafeDocument.body;
+ return sanitizeDocument(unsafeDocument, unsafeNode, options);
+}
diff --git a/shared/components/src/utils/sanitize-html/common.ts b/shared/components/src/utils/sanitize-html/common.ts
new file mode 100644
index 0000000..38b3b2e
--- /dev/null
+++ b/shared/components/src/utils/sanitize-html/common.ts
@@ -0,0 +1,176 @@
+type AllowedTags = Set<string>;
+
+interface AllowedAttributes {
+ [tagName: string]: Set<string>;
+}
+
+export interface SanitizeHtmlOptions {
+ allowedTags?: string[];
+ extraAllowedTags?: string[];
+ keepChildrenWhenRemovingParent?: boolean;
+
+ /**
+ * When true, replaces all &nbsp; entities with regular spaces
+ * to prevent unwanted line breaks in the rendered HTML
+ */
+ removeNbsp?: boolean;
+
+ /**
+ * AllowedAttributes should be an object with tag name keys and array values
+ * containing all of the attributes allowed for that tag:
+ *
+ * { 'p': ['class'], 'div': ['role', 'aria-hidden'] }
+ *
+ * The above allows ONLY the class attribute for <p> and ONLY the role and
+ * aria-hidden attributes for <div>.
+ */
+ allowedAttributes?: {
+ [tagName: string]: string[];
+ };
+}
+
+export const DEFAULT_SAFE_TAGS: string[] = [
+ 'strong',
+ 'em',
+ 'b',
+ 'i',
+ 'u',
+ 'br',
+];
+const DEFAULT_SAFE_ATTRS = {};
+
+/**
+ * Sanitizes HTML by removing all tags and attributes that aren't explicitly allowed.
+ */
+export function sanitizeDocument(
+ unsafeDocument: Document,
+ unsafeNode: Node | DocumentFragment,
+ {
+ allowedTags,
+ extraAllowedTags,
+ allowedAttributes = DEFAULT_SAFE_ATTRS,
+ keepChildrenWhenRemovingParent,
+ removeNbsp,
+ }: SanitizeHtmlOptions = {},
+): string {
+ if (allowedTags && extraAllowedTags) {
+ throw new Error(
+ 'sanitizeHtml got both allowedTags and extraAllowedTags',
+ );
+ }
+
+ const allowedTagsSet = new Set([
+ ...(extraAllowedTags || []),
+ ...(allowedTags || DEFAULT_SAFE_TAGS),
+ ]);
+
+ const allowedAttributeSets = {};
+ for (const [tag, attributes] of Object.entries(allowedAttributes)) {
+ allowedAttributeSets[tag] = new Set(attributes);
+ }
+
+ const sanitizedContainer = unsafeDocument.createElement('div');
+
+ for (const child of [...unsafeNode.childNodes]) {
+ const sanitizedChildArray = sanitizeNode(
+ child as Element,
+ allowedTagsSet,
+ allowedAttributeSets,
+ keepChildrenWhenRemovingParent,
+ );
+ sanitizedChildArray.forEach((node) => {
+ sanitizedContainer.appendChild(node);
+ });
+ }
+
+ let html = sanitizedContainer.innerHTML;
+
+ // Replace &nbsp; with regular spaces if removeNbsp option is enabled
+ if (removeNbsp) {
+ html = html.replace(/&nbsp;/g, ' ');
+ }
+
+ return html;
+}
+
+function sanitizeNode(
+ node: Element,
+ allowedTags: AllowedTags,
+ allowedAttributes: AllowedAttributes,
+ keepChildrenWhenRemovingParent: boolean,
+): Node[] | Element[] {
+ // Plain text is safe as is
+ // NOTE: The lowercase node (instead of Node) is intentional. Node is only
+ // accessible in browser. In Node.js, it depends on jsdom (which we
+ // avoid importing to exclude from the clientside vendor bundle).
+ // Instead of passing down window.Node or jsdom.Node depending on
+ // context, we rely on the fact that instances of Node (of which node
+ // will be one) will also have these constants set on them.
+ if (
+ ([node.TEXT_NODE, node.CDATA_SECTION_NODE] as number[]).includes(
+ node.nodeType,
+ )
+ ) {
+ return [node];
+ }
+
+ // Refuse anything that isn't a tag or one of the allowed tags
+ const tagName = (node.tagName || '').toLowerCase();
+
+ if (!allowedTags.has(tagName)) {
+ // when keepChildrenWhenRemovingParent is true
+ // we check children for valid nodes as well
+ if (keepChildrenWhenRemovingParent) {
+ return sanitizeChildren(
+ node,
+ allowedTags,
+ allowedAttributes,
+ keepChildrenWhenRemovingParent,
+ );
+ }
+ return [];
+ }
+
+ // Reconstruct node with only the allowedAttributes and sanitize its children
+ const sanitized = node.ownerDocument.createElement(tagName);
+ const currentlyAllowedAttributes = allowedAttributes[tagName] || new Set();
+
+ for (const { name, nodeValue: value } of [...node.attributes]) {
+ if (currentlyAllowedAttributes.has(name)) {
+ sanitized.setAttribute(name, value);
+ }
+ }
+
+ const children = sanitizeChildren(
+ node,
+ allowedTags,
+ allowedAttributes,
+ keepChildrenWhenRemovingParent,
+ );
+
+ children.forEach((child) => {
+ sanitized.appendChild(child);
+ });
+
+ return [sanitized];
+}
+
+const sanitizeChildren = (
+ node: Element,
+ allowedTags: AllowedTags,
+ allowedAttributes: AllowedAttributes,
+ tagsToConvertToText: boolean,
+): Node[] => {
+ const children = [...node.childNodes]
+ .map((childNode) =>
+ sanitizeNode(
+ childNode as Element,
+ allowedTags,
+ allowedAttributes,
+ tagsToConvertToText,
+ ),
+ )
+ .flat();
+
+ return children;
+};
diff --git a/shared/components/src/utils/sanitize.ts b/shared/components/src/utils/sanitize.ts
new file mode 100644
index 0000000..107a543
--- /dev/null
+++ b/shared/components/src/utils/sanitize.ts
@@ -0,0 +1,32 @@
+// Take care with < (which has special meaning inside script tags)
+// See: https://github.com/sveltejs/kit/blob/ff9a27b4/packages/kit/src/runtime/server/page/serialize_data.js#L4-L28
+const replacements = {
+ '<': '\\u003C',
+ '\u2028': '\\u2028',
+ '\u2029': '\\u2029',
+};
+
+const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
+
+/**
+ * Serializes a POJO into a HTML <script> tag that can be read clientside by
+ * `deserializeServerData`.
+ *
+ * Use this to share data between serverside and clientside. Include the
+ * returned HTML in the response to a client to allow it to read this data.
+ *
+ * @param data data to serialize
+ * @returns serialized data (or empty string if serialization fails)
+ */
+export function serializeJSONData(data: object): string {
+ try {
+ return JSON.stringify(data).replace(
+ pattern,
+ (match) => replacements[match],
+ );
+ } catch (e) {
+ // Don't let recursive data (or other non-serializable things) throw.
+ // We'd rather just let the serialize no-op to avoid breaking consumers.
+ return '';
+ }
+}
diff --git a/shared/components/src/utils/scrollByPolyfill.ts b/shared/components/src/utils/scrollByPolyfill.ts
new file mode 100644
index 0000000..1a73a4f
--- /dev/null
+++ b/shared/components/src/utils/scrollByPolyfill.ts
@@ -0,0 +1,143 @@
+// COPIED FROM
+// https://github.pie.apple.com/amp-ui/ember-ui-media-shelf/blob/580ff07a546771bce8b3d85494c6268860e97215/addon/-private/scroll-by-polyfill.js
+
+const SCROLL_TIME = 468;
+const Element =
+ typeof window !== 'undefined' ? window.HTMLElement || window.Element : null;
+
+let originalScrollBy;
+
+/**
+ * returns result of applying ease math function to a number
+ * @method ease
+ * @param {Number} k
+ * @returns {Number}
+ */
+function ease(k: number): number {
+ return 0.5 * (1 - Math.cos(Math.PI * k));
+}
+
+// define timing method
+const now: () => number =
+ typeof window !== 'undefined' && window?.performance?.now
+ ? window.performance.now.bind(window.performance)
+ : Date.now;
+
+/**
+ * changes scroll position inside an element
+ * @method scrollElement
+ * @param {Number} x
+ * @returns {undefined}
+ */
+function scrollElement(x: number): void {
+ this.scrollLeft = x;
+}
+
+/**
+ * self invoked function that, given a context, steps through scrolling
+ * @method step
+ * @param {Object} context
+ * @returns {undefined}
+ */
+type Context = {
+ startTime: number;
+ startX: number;
+ x: number;
+ method: (x: number) => void;
+ scrollable: HTMLElement;
+};
+function step(context: Context): void {
+ const time = now();
+ let elapsed = (time - context.startTime) / SCROLL_TIME;
+
+ // avoid elapsed times higher than one
+ elapsed = Math.min(1, elapsed);
+
+ // apply easing to elapsed time
+ const value = ease(elapsed);
+
+ const currentX = context.startX + (context.x - context.startX) * value;
+
+ context.method.call(context.scrollable, currentX);
+
+ // scroll more if we have not reached our destination
+ if (currentX !== context.x) {
+ window.requestAnimationFrame(step.bind(window, context));
+ }
+}
+
+/**
+ * scrolls window or element with a smooth behavior
+ * @method smoothScroll
+ * @param {Object|Node} el
+ * @param {Number} x
+ * @returns {undefined}
+ */
+function smoothScroll(el: HTMLElement, x: number): void {
+ const startTime = now();
+ // define scroll context
+ const startX = el.scrollLeft;
+ const method = scrollElement;
+
+ // scroll looping over a frame
+ step({
+ scrollable: el,
+ method,
+ startTime,
+ startX,
+ x,
+ });
+}
+
+let polyfillHasRun = false;
+/**
+ * ripped partially from https://github.com/iamdustan/smoothscroll/blob/master/src/smoothscroll.js
+ * Only polyfill horizontal scroll space to avoid unexpected behaviour in parent apps
+ *
+ * @method scrollByPolyfill
+ */
+export default function scrollByPolyfill(): void {
+ // return if scroll behavior is supported
+ if ('scrollBehavior' in document.documentElement.style || polyfillHasRun) {
+ return;
+ }
+
+ // if prefers-reduce-motion && need polyfill, navigate shelf immediately without easing
+ const motionMediaQuery = window.matchMedia(
+ '(prefers-reduced-motion: reduce)',
+ );
+ function addScrollByToProto() {
+ if (motionMediaQuery.matches) {
+ if (originalScrollBy) {
+ Element.prototype.scrollBy = originalScrollBy;
+ }
+ return;
+ }
+
+ function scrollByPoly(options: ScrollToOptions): void;
+ function scrollByPoly(x: number, _y: number): void;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ function scrollByPoly(
+ paramOne: number | ScrollToOptions,
+ _paramTwo?: number,
+ ): void {
+ let xValue = 0;
+ if (typeof paramOne === 'number') {
+ xValue = paramOne;
+ } else if (typeof paramOne === 'object') {
+ xValue = paramOne.left || 0;
+ }
+
+ const moveByX = this.scrollLeft + xValue;
+ smoothScroll(this, moveByX);
+ }
+
+ originalScrollBy = Element.prototype.scrollBy;
+ Element.prototype.scrollBy = scrollByPoly;
+ }
+
+ motionMediaQuery.addListener(addScrollByToProto);
+
+ addScrollByToProto();
+ polyfillHasRun = true;
+}
diff --git a/shared/components/src/utils/shelfAspectRatio.ts b/shared/components/src/utils/shelfAspectRatio.ts
new file mode 100644
index 0000000..eeb977d
--- /dev/null
+++ b/shared/components/src/utils/shelfAspectRatio.ts
@@ -0,0 +1,75 @@
+import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
+import { setContext, getContext, hasContext } from 'svelte';
+import { derived, writable } from 'svelte/store';
+import type { Readable } from 'svelte/store';
+import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
+import type { AspectRatioOverrideConfig } from '@amp/web-app-components/src/components/Shelf/types';
+
+const SHELF_ASPECT_RATIO_KEY = 'shelf-aspect-ratio';
+
+export const getShelfAspectRatioContext = (): {
+ shelfAspectRatio: Readable<string>;
+ addProfile: (profile: string | Profile) => void;
+} => {
+ return getContext(SHELF_ASPECT_RATIO_KEY);
+};
+
+export const hasShelfAspectRatioContext = () =>
+ hasContext(SHELF_ASPECT_RATIO_KEY);
+
+const createShelfAspectRatioStore = (config: AspectRatioOverrideConfig) => {
+ const { subscribe, update } = writable(new Map() as Map<string, number>);
+
+ const addProfile = (profile: string) => {
+ const ratio = getAspectRatio(profile).toFixed(2);
+
+ update((ratiosCount) => {
+ const currentCount = ratiosCount.get(ratio);
+ const newCount = ratiosCount.has(ratio) ? currentCount + 1 : 0;
+ ratiosCount.set(ratio, newCount);
+ return ratiosCount;
+ });
+ };
+
+ const aspectRatioStore = {
+ subscribe,
+ addProfile,
+ };
+
+ const shelfAspectRatio = derived(aspectRatioStore, ($store) => {
+ let aspectRatio: string = null;
+
+ // Don't set shelf aspect ratio when only 1 ratio is found
+ //
+ // This allows e.g. a shelf with only tall artwork Powerswooshes to use
+ // their native 3:4 aspect ratio, even when the shelf is set to use the
+ // fixed 1:1 aspect ratio or a dominant aspect ratio.
+ if ($store.size > 1) {
+ if (config.type === 'fixed') {
+ aspectRatio = config.aspectRatio;
+ } else if (config.type === 'dominant') {
+ let highestCount = 0;
+ for (const [ratio, count] of $store.entries()) {
+ if (highestCount < count) {
+ aspectRatio = ratio;
+ highestCount = count;
+ }
+ }
+ }
+ }
+
+ return aspectRatio;
+ });
+
+ return {
+ shelfAspectRatio,
+ addProfile,
+ };
+};
+
+export const createShelfAspectRatioContext = (
+ config: AspectRatioOverrideConfig,
+) => {
+ setContext(SHELF_ASPECT_RATIO_KEY, createShelfAspectRatioStore(config));
+ return getShelfAspectRatioContext();
+};
diff --git a/shared/components/src/utils/should-show-navigation-item.ts b/shared/components/src/utils/should-show-navigation-item.ts
new file mode 100644
index 0000000..194628a
--- /dev/null
+++ b/shared/components/src/utils/should-show-navigation-item.ts
@@ -0,0 +1,25 @@
+export function shouldShowNavigationItem(
+ visibilityPreferencesKey: string | null,
+ isEditing: boolean,
+ data: Record<string, boolean> | null,
+ itemVisibilityPreferenceKey: string,
+): boolean {
+ // If there are no visibility preferences,
+ // the item should always be shown.
+ if (!visibilityPreferencesKey) {
+ return true;
+ }
+
+ // If the visibility preference of an item
+ // is in an editing state, it should be shown.
+ if (isEditing) {
+ return true;
+ }
+
+ // Show the item if the visibility preference is to show it.
+ if (data && data[itemVisibilityPreferenceKey]) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/shared/components/src/utils/throttle.ts b/shared/components/src/utils/throttle.ts
new file mode 100644
index 0000000..b5e36bc
--- /dev/null
+++ b/shared/components/src/utils/throttle.ts
@@ -0,0 +1,49 @@
+/* eslint-disable import/prefer-default-export */
+/**
+ * @name throttle
+ * @description
+ * Creates a throttled function that only invokes func at most once per every limit time (ms).
+ *
+ * *NOTE: this does not capture or recall all functions that were triggered.
+ * This will drop function calls that happen during the throttle time*
+ * @param limit - time to wait between calls in ms
+ * @example
+ * Normal event
+ * event | | | |
+ * time ----------------
+ * callback | | | |
+ *
+ * Throttled event [300ms]
+ * event | | | |
+ * time ----------------
+ * callback | | |
+ * [300] [300]
+ */
+
+export function throttle<T extends []>(
+ func: (..._: T) => unknown,
+ limit: number,
+): (..._: T) => void {
+ let lastTimeoutId;
+ let lastCallTime: number;
+
+ return function throttled(...args) {
+ const nextCall = () => {
+ func.apply(this, args);
+ lastCallTime = Date.now();
+ };
+
+ if (!lastCallTime) {
+ nextCall();
+ } else {
+ clearTimeout(lastTimeoutId);
+ const timeBetweenCalls = Date.now() - lastCallTime;
+ const waitTime = Math.max(0, limit - timeBetweenCalls);
+ lastTimeoutId = setTimeout(() => {
+ if (timeBetweenCalls >= limit) {
+ nextCall();
+ }
+ }, waitTime);
+ }
+ };
+}
diff --git a/shared/components/src/utils/uniqueId.ts b/shared/components/src/utils/uniqueId.ts
new file mode 100644
index 0000000..3a6d21d
--- /dev/null
+++ b/shared/components/src/utils/uniqueId.ts
@@ -0,0 +1,71 @@
+import { getContext } from 'svelte';
+
+export const UNIQUE_ID_CONTEXT_NAME = 'amp-web-unique-id';
+
+interface UniqueContext {
+ nextId: number;
+}
+
+// TODO: rdar://84029606 (Extract logger into shared util)
+interface Logger {
+ warn(...args: any[]): string;
+}
+interface LoggerFactory {
+ loggerFor(name: string): Logger;
+}
+
+export function initializeUniqueIdContext(
+ context: Map<string, unknown>,
+ loggerFactory: LoggerFactory,
+): void {
+ const logger = loggerFactory.loggerFor('uniqueIdContext');
+
+ if (context.has(UNIQUE_ID_CONTEXT_NAME)) {
+ logger.warn(
+ `${UNIQUE_ID_CONTEXT_NAME} context has already been created. Cannot be created more than once`,
+ );
+ } else {
+ const INITAL_STATE: UniqueContext = { nextId: 0 };
+ context.set(UNIQUE_ID_CONTEXT_NAME, INITAL_STATE);
+ }
+}
+
+/**
+ * Creates a unique Id string based on string provided
+ *
+ * @returns unique id string
+ */
+export type UniqueIdGenerator = () => string;
+
+// Custom elements most likely will not be used in an environment has that initialized the Svelte
+// context. Components that are later wrapped by a custom element should use this function so that
+// they can generate unique ids automatically when used inside a Svelte app, but not throw an error
+// when used in other contexts.
+//
+export function maybeGetUniqueIdGenerator(): UniqueIdGenerator | undefined {
+ const UNIQUE_ID_PREFIX = 'uid-';
+ const state: UniqueContext = getContext(UNIQUE_ID_CONTEXT_NAME);
+ const isNextIdANumber = typeof state?.nextId === 'number';
+
+ if (!isNextIdANumber) {
+ return;
+ }
+
+ return () => {
+ const id = `${UNIQUE_ID_PREFIX}${state.nextId}`;
+ state.nextId += 1;
+ return id;
+ };
+}
+
+export function getUniqueIdGenerator(): UniqueIdGenerator {
+ const uniqueIdGenerator = maybeGetUniqueIdGenerator();
+
+ if (!uniqueIdGenerator) {
+ throw new Error(
+ `${UNIQUE_ID_CONTEXT_NAME} context has not been initialized. Initialize at application bootstrap.`,
+ );
+ }
+
+ return uniqueIdGenerator;
+}