diff options
Diffstat (limited to 'shared/components')
90 files changed, 10807 insertions, 0 deletions
diff --git a/shared/components/assets/icons/arrow.svg b/shared/components/assets/icons/arrow.svg new file mode 100644 index 0000000..99e4e93 --- /dev/null +++ b/shared/components/assets/icons/arrow.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20height='16'%20width='16'%20viewBox='0%200%2016%2016'%3e%3cpath%20d='M1.559%2016L13.795%203.764v8.962H16V0H3.274v2.205h8.962L0%2014.441%201.559%2016z'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/assets/icons/chevron.svg b/shared/components/assets/icons/chevron.svg new file mode 100644 index 0000000..4accf4b --- /dev/null +++ b/shared/components/assets/icons/chevron.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20stroke-linejoin='round'%20viewBox='0%200%2036%2064'%20width='36'%20height='64'%3e%3cpath%20d='m3.344%2064c.957%200%201.768-.368%202.394-.994l29.2-28.538c.701-.7%201.069-1.547%201.069-2.468%200-.957-.368-1.841-1.068-2.467l-29.165-28.502c-.662-.661-1.473-1.03-2.43-1.03-1.914-.001-3.35%201.471-3.35%203.386%200%20.884.367%201.767.956%202.393l26.808%2026.22-26.808%2026.218a3.5%203.5%200%200%200%20-.956%202.395c0%201.914%201.435%203.387%203.35%203.387z'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/assets/icons/close.svg b/shared/components/assets/icons/close.svg new file mode 100644 index 0000000..33ceaf8 --- /dev/null +++ b/shared/components/assets/icons/close.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20width='18px'%20height='18px'%20version='1.1'%20viewBox='0%200%2018%2018'%20aria-hidden='true'%3e%3cpath%20d='M1.2%2018C.6%2018%200%2017.5%200%2016.8c0-.4.1-.6.4-.8l7-7-7-7c-.3-.2-.4-.5-.4-.8C0%20.5.6%200%201.2%200c.3%200%20.6.1.8.3l7%207%207-7c.2-.2.5-.3.8-.3.6%200%201.2.5%201.2%201.2%200%20.3-.1.6-.4.8l-7%207%207%207c.2.2.4.5.4.8%200%20.7-.6%201.2-1.2%201.2-.3%200-.6-.1-.8-.3l-7-7-7%207c-.2.1-.5.3-.8.3z'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/assets/icons/search.svg b/shared/components/assets/icons/search.svg new file mode 100644 index 0000000..51acbf1 --- /dev/null +++ b/shared/components/assets/icons/search.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20height='16'%20width='16'%20viewBox='0%200%2016%2016'%3e%3cpath%20d='M11.87%2010.835c.018.015.035.03.051.047l3.864%203.863a.735.735%200%201%201-1.04%201.04l-3.863-3.864a.744.744%200%200%201-.047-.051%206.667%206.667%200%201%201%201.035-1.035zM6.667%2012a5.333%205.333%200%201%200%200-10.667%205.333%205.333%200%200%200%200%2010.667z'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/assets/icons/star-filled.svg b/shared/components/assets/icons/star-filled.svg new file mode 100644 index 0000000..30ce915 --- /dev/null +++ b/shared/components/assets/icons/star-filled.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20class='icon'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M11.5587783,56.6753946%20C12.6607967,57.5354863%2014.0584114,57.239835%2015.7248701,56.0303671%20L29.9432738,45.5748551%20L44.1885399,56.0303671%20C45.8549435,57.239835%2047.2256958,57.5354863%2048.3545766,56.6753946%20C49.4565949,55.8422203%2049.6985766,54.4714239%2049.0265215,52.5093414%20L43.4090353,35.7913212%20L57.7616957,25.4702202%20C59.4284847,24.2875597%2060.1000443,23.0511744%2059.6701361,21.7072844%20C59.2402278,20.4171743%2057.9769251,19.7720918%2055.9072003,19.7989542%20L38.3022646,19.9065138%20L32.9535674,3.10783487%20C32.3084848,1.11886239%2031.3408885,0.12440367%2029.9432738,0.12440367%20C28.5724665,0.12440367%2027.6048701,1.11886239%2026.9597875,3.10783487%20L21.6110903,19.9065138%20L4.00609944,19.7989542%20C1.93648476,19.7720918%200.673237047,20.4171743%200.243218696,21.7072844%20C-0.213717085,23.0511744%200.485090256,24.2875597%202.15154898,25.4702202%20L16.5043196,35.7913212%20L10.8868334,52.5093414%20C10.2148884,54.4714239%2010.456815,55.8422203%2011.5587783,56.6753946%20Z'%20transform='translate(2%203.376)'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/assets/icons/star-hollow.svg b/shared/components/assets/icons/star-hollow.svg new file mode 100644 index 0000000..a359cef --- /dev/null +++ b/shared/components/assets/icons/star-hollow.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20class='icon'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M11.5587783,56.6753946%20C12.6607967,57.5354863%2014.0584114,57.239835%2015.7248701,56.0303671%20L29.9432738,45.5748551%20L44.1885399,56.0303671%20C45.8549435,57.239835%2047.2256958,57.5354863%2048.3545766,56.6753946%20C49.4565949,55.8422203%2049.6985766,54.4714239%2049.0265215,52.5093414%20L43.4090353,35.7913212%20L57.7616957,25.4702202%20C59.4284847,24.2875597%2060.1000443,23.0511744%2059.6701361,21.7072844%20C59.2402278,20.4171743%2057.9769251,19.7720918%2055.9072003,19.7989542%20L38.3022646,19.9065138%20L32.9535674,3.10783487%20C32.3084848,1.11886239%2031.3408885,0.12440367%2029.9432738,0.12440367%20C28.5724665,0.12440367%2027.6048701,1.11886239%2026.9597875,3.10783487%20L21.6110903,19.9065138%20L4.00609944,19.7989542%20C1.93648476,19.7720918%200.673237047,20.4171743%200.243218696,21.7072844%20C-0.213717085,23.0511744%200.485090256,24.2875597%202.15154898,25.4702202%20L16.5043196,35.7913212%20L10.8868334,52.5093414%20C10.2148884,54.4714239%2010.456815,55.8422203%2011.5587783,56.6753946%20Z%20M15.4292187,51.3535927%20C15.3754389,51.2998405%2015.4023013,51.2729616%2015.4292187,51.1116937%20L20.777916,35.7375413%20C21.1542096,34.6893028%2020.9391453,33.8561285%2019.9984664,33.2110459%20L6.61323706,23.9650459%20C6.47887008,23.8844037%206.4520077,23.8306789%206.47887008,23.7500367%20C6.50573247,23.6693945%206.55951229,23.6693945%206.72079669,23.6693945%20L22.9818976,23.9650459%20C24.0838609,23.9919083%2024.7827233,23.5350276%2025.1320995,22.4330092%20L29.8088518,6.87071561%20C29.8357142,6.7094312%2029.889494,6.65570643%2029.9432738,6.65570643%20C30.0238609,6.65570643%2030.0776408,6.7094312%2030.1045032,6.87071561%20L34.7812555,22.4330092%20C35.1306866,23.5350276%2035.829494,23.9919083%2036.9315123,23.9650459%20L53.1923381,23.6693945%20C53.3536225,23.6693945%2053.4075674,23.6693945%2053.4345399,23.7500367%20C53.4615124,23.8306789%2053.4075674,23.8844037%2053.300228,23.9650459%20L39.9149435,33.2110459%20C38.9742096,33.8561285%2038.7592004,34.6893028%2039.135494,35.7375413%20L44.4841912,51.1116937%20C44.5110536,51.2729616%2044.537916,51.2998405%2044.4841912,51.3535927%20C44.4304114,51.4342294%2044.3497692,51.3804716%2044.2422646,51.2998405%20L31.3140261,41.4356698%20C30.4539343,40.7637248%2029.4594206,40.7637248%2028.5993839,41.4356698%20L15.6710903,51.2998405%20C15.5635857,51.3804716%2015.4829435,51.4342294%2015.4292187,51.3535927%20Z'%20transform='translate(2%203.376)'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/assets/shelf/chevron-compact-left.svg b/shared/components/assets/shelf/chevron-compact-left.svg new file mode 100644 index 0000000..bef9ce1 --- /dev/null +++ b/shared/components/assets/shelf/chevron-compact-left.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%209%2031'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M27.49%2075.5a4.59%204.59%200%200%200%204.15%203.07c2.9%200%205.05-2.1%205.05-4.95%200-1.5-.79-3.38-1.28-4.62L22.07%2035.05%2035.4%201.12c.49-1.26%201.28-3.18%201.28-4.63a4.85%204.85%200%200%200-5.05-4.95%204.57%204.57%200%200%200-4.15%203.11l-13.1%2033.29c-.86%202.21-1.93%204.97-1.93%207.11%200%202.15%201.07%204.86%201.93%207.12l13.1%2033.33Z'%20transform='matrix(.35086%200%200%20.35086%20-4.37%202.97)'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/shared/components/config/components/artwork.ts b/shared/components/config/components/artwork.ts new file mode 100644 index 0000000..daca473 --- /dev/null +++ b/shared/components/config/components/artwork.ts @@ -0,0 +1,103 @@ +// default params used by artwork component. +import type { Profile } from '@amp/web-app-components/src/components/Artwork/types'; +import type { Breakpoints } from '@amp/web-app-components/src/types'; +import { ASPECT_RATIOS } from '@amp/web-app-components/src/components/Artwork/constants'; + +export type ArtworkProfileMap<ProfileName extends string = string> = Map< + ProfileName, + Profile +>; +export interface ArtworkConfigOptions { + BREAKPOINTS?: Breakpoints; + PROFILES?: ArtworkProfileMap; +} + +interface ArtworkConfig { + get: () => ArtworkConfigOptions; + set: (obj: ArtworkConfigOptions) => void; +} + +function artworkConfig(): ArtworkConfig { + const { + HD, + ONE, + HERO, + THREE_QUARTERS, + SUPER_HERO_WIDE, + UBER, + ONE_THIRD, + HD_ASPECT_RATIO, + EDITORIAL_DEFAULT, + } = ASPECT_RATIOS; + let config: ArtworkConfigOptions = { + BREAKPOINTS: { + xsmall: { + max: 739, + }, + small: { + min: 740, + max: 999, + }, + medium: { + min: 1000, + max: 1319, + }, + large: { + min: 1320, + max: 1679, + }, + xlarge: { + min: 1680, + }, + }, + PROFILES: new Map([ + ['brick', [[340, 340, 290, 290], HD, 'sr']], + ['brick-sporting-event', [[340, 340, 290, 290], HD, 'sh']], + ['product', [[500, 500, 300, 270], ONE, 'bb']], + ['episode', [[330, 330, 305, 295], HD, 'sr']], + [ + 'editorial-card', + [[530, 530, 480, 300, 300], EDITORIAL_DEFAULT, 'fa'], + ], + ['editorial-card-cover-artwork', [[60], ONE, 'cc']], + ['editorial-card-video-art', [[88], HD_ASPECT_RATIO, 'mv']], + ['hero', [[530, 530, 600, 450], HERO, 'sr']], + ['superHeroLockup', [[330, 330, 305, 295], THREE_QUARTERS, 'bb']], + ['superHeroTall', [[600, 600, 450], THREE_QUARTERS, 'sr']], + [ + 'superHeroWide', + [[1200, 1200, 900, 600, 450], SUPER_HERO_WIDE, 'sr'], + ], + ['uber', [[1200], UBER, 'bb']], + ['episode-lockup', [[316, 316, 296, 296], ONE, 'cc']], + ['upsell-artwork', [[94], ONE, 'cc']], + ['upsell-wordmark', [[140], 140 / 14, 'bb']], + ['ellipse-lockup', [[243, 243, 220, 190, 160], ONE, 'cc']], + ['standard', [[243, 243, 220, 190, 160], ONE, 'bb']], + ['powerswoosh', [[300], ONE, 'cc']], + ['powerswooshTall', [[600, 450], THREE_QUARTERS, 'sr']], + ['category-brick', [[1040, 1040, 1040, 680], ONE_THIRD, 'sr']], + ['info-fullscreen', [[600, 600, 450], ONE, 'bb']], + ['track-list', [[40], ONE, 'bb']], + ]), + }; + + const setConfig = (obj: ArtworkConfigOptions) => { + config = { + PROFILES: new Map([...config.PROFILES, ...obj.PROFILES]), + BREAKPOINTS: { + ...config.BREAKPOINTS, + ...(obj?.BREAKPOINTS ?? {}), + }, + }; + }; + + const getConfig = (): ArtworkConfigOptions => config; + + return { + get: getConfig, + set: setConfig, + }; +} + +export const ArtworkConfig = artworkConfig(); diff --git a/shared/components/config/components/shelf.ts b/shared/components/config/components/shelf.ts new file mode 100644 index 0000000..1146e3d --- /dev/null +++ b/shared/components/config/components/shelf.ts @@ -0,0 +1,116 @@ +/* eslint-disable object-curly-newline */ +import type { Size } from '@amp/web-app-components/src/types'; +import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; + +/** + * Used to customize the shared shelf + * + * @param GRID_MAX_CONTENT - Sets the max content size of the column for each viewport + * @param GRID_ROW_GAP - Sets the row gap for a shelf in each viewport + * @param GRID_COL_GAP - Sets the column gap for a shelf in each viewport + * @param GRID_VALUES - Sets the number of items to show in a column of the grid for each viewport + * + * @example + * const ShelvesConfig = { + * GRID_MAX_CONTENT: { + * FooShelf: { xsmall: '298px' }, + * }, + * GRID_COL_GAP: { + * FooShelf: { xsmall: '10px', small:'20px', medium:'20px', large:'20px', xlarge: '30px' } + * }, + * GRID_ROW_GAP: { + * FooShelf: { xsmall: '10px', small:'20px', medium:'20px', large:'20px', xlarge: '30px' } + * }, + * GRID_VALUES: { + * FooShelf: { xsmall: 1, small: 3, medium: 5, large: 6, xlarge: 10 } + * } + * } + */ +export interface ShelfConfigOptions { + /** + * Sets the max size of the column for each viewport + * (NOTE: these values will override GRID_VALUES) + */ + GRID_MAX_CONTENT: { + [key in GridType]: { [value in Size]?: string }; + }; + /** + * Sets the row gap for a shelf in each viewport + * - Default for all shelves is { xsmall: '24px', small: '24px', medium: '24px', large: '24px', xlarge: '24px' } + */ + GRID_ROW_GAP: { + [key in GridType]?: { [value in Size]?: number | null }; + }; + /** + * Sets the column gap for a shelf in each viewport + * - Default for all shelves is { xsmall: '10px', small: '20px', medium: '20px', large: '20px', xlarge: '20px' } + */ + GRID_COL_GAP: { + [key in GridType]?: { [value in Size]?: string | null }; + }; + /** + * Sets the number of columns in the grid for each viewport + * (NOTE: this value will be overridden by values in GRID_MAX_CONTENT) + */ + GRID_VALUES: { + [key in GridType]: { [value in Size]: number | null }; + }; +} + +// Grid values correspond with dynamic-grids.scss +function ShelfConfigInit() { + let config: ShelfConfigOptions = { + GRID_MAX_CONTENT: { + A: { xsmall: '298px' }, + B: { xsmall: '298px' }, + C: { xsmall: '200px' }, + D: { xsmall: '144px' }, + E: { xsmall: '144px' }, + F: { xsmall: '270px' }, + G: { xsmall: '144px' }, + H: { xsmall: '94px' }, + I: { xsmall: '144px' }, + EllipseA: {}, + Spotlight: {}, + Single: {}, + '1-1-2-3': {}, + '2-2-3-4': { xsmall: '270px' }, + '1-2-2-2': {}, + }, + GRID_COL_GAP: {}, + GRID_ROW_GAP: { + None: { xsmall: 0, small: 0, medium: 0, large: 0, xlarge: 0 }, + '1-2-2-2': { xsmall: 0, small: 0, medium: 0, large: 0, xlarge: 0 }, + }, + GRID_VALUES: { + A: { xsmall: null, small: 2, medium: 2, large: 3, xlarge: 3 }, + B: { xsmall: null, small: 2, medium: 3, large: 4, xlarge: 4 }, + C: { xsmall: null, small: 3, medium: 4, large: 5, xlarge: 5 }, + D: { xsmall: null, small: 4, medium: 5, large: 8, xlarge: 8 }, + E: { xsmall: null, small: 5, medium: 9, large: 10, xlarge: 10 }, + F: { xsmall: null, small: 2, medium: 3, large: 3, xlarge: 3 }, + G: { xsmall: null, small: 4, medium: 5, large: 6, xlarge: 6 }, + H: { xsmall: null, small: 6, medium: 8, large: 10, xlarge: 10 }, + I: { xsmall: null, small: 5, medium: 6, large: 8, xlarge: 8 }, + Single: { xsmall: 1, small: 1, medium: 1, large: 1, xlarge: 1 }, + EllipseA: { xsmall: 2, small: 4, medium: 6, large: 6, xlarge: 6 }, + Spotlight: { xsmall: 1, small: 1, medium: 1, large: 1, xlarge: 1 }, + '1-1-2-3': { xsmall: 1, small: 1, medium: 2, large: 3, xlarge: 3 }, + '2-2-3-4': { xsmall: 2, small: 2, medium: 3, large: 4, xlarge: 4 }, + '1-2-2-2': { xsmall: 1, small: 2, medium: 2, large: 2, xlarge: 2 }, + }, + }; + + const get = () => config; + + const set = (obj: ShelfConfigOptions) => { + config = { ...config, ...obj }; + }; + + return { + set, + get, + }; +} + +export const ShelfConfig = ShelfConfigInit(); diff --git a/shared/components/node_modules/intersection-observer-admin/dist/intersection-observer-admin.es5.js b/shared/components/node_modules/intersection-observer-admin/dist/intersection-observer-admin.es5.js new file mode 100644 index 0000000..c6051f1 --- /dev/null +++ b/shared/components/node_modules/intersection-observer-admin/dist/intersection-observer-admin.es5.js @@ -0,0 +1,428 @@ +var Registry = /** @class */ (function () { + function Registry() { + this.registry = new WeakMap(); + } + Registry.prototype.elementExists = function (elem) { + return this.registry.has(elem); + }; + Registry.prototype.getElement = function (elem) { + return this.registry.get(elem); + }; + /** + * administrator for lookup in the future + * + * @method add + * @param {HTMLElement | Window} element - the item to add to root element registry + * @param {IOption} options + * @param {IOption.root} [root] - contains optional root e.g. window, container div, etc + * @param {IOption.watcher} [observer] - optional + * @public + */ + Registry.prototype.addElement = function (element, options) { + if (!element) { + return; + } + this.registry.set(element, options || {}); + }; + /** + * @method remove + * @param {HTMLElement|Window} target + * @public + */ + Registry.prototype.removeElement = function (target) { + this.registry.delete(target); + }; + /** + * reset weak map + * + * @method destroy + * @public + */ + Registry.prototype.destroyRegistry = function () { + this.registry = new WeakMap(); + }; + return Registry; +}()); + +var noop = function () { }; +var CallbackType; +(function (CallbackType) { + CallbackType["enter"] = "enter"; + CallbackType["exit"] = "exit"; +})(CallbackType || (CallbackType = {})); +var Notifications = /** @class */ (function () { + function Notifications() { + this.registry = new Registry(); + } + /** + * Adds an EventListener as a callback for an event key. + * @param type 'enter' or 'exit' + * @param key The key of the event + * @param callback The callback function to invoke when the event occurs + */ + Notifications.prototype.addCallback = function (type, element, callback) { + var _a, _b; + var entry; + if (type === CallbackType.enter) { + entry = (_a = {}, _a[CallbackType.enter] = callback, _a); + } + else { + entry = (_b = {}, _b[CallbackType.exit] = callback, _b); + } + this.registry.addElement(element, Object.assign({}, this.registry.getElement(element), entry)); + }; + /** + * @hidden + * Executes registered callbacks for key. + * @param type + * @param element + * @param data + */ + Notifications.prototype.dispatchCallback = function (type, element, data) { + if (type === CallbackType.enter) { + var _a = this.registry.getElement(element).enter, enter = _a === void 0 ? noop : _a; + enter(data); + } + else { + // no element in WeakMap possible because element may be removed from DOM by the time we get here + var found = this.registry.getElement(element); + if (found && found.exit) { + found.exit(data); + } + } + }; + return Notifications; +}()); + +var __extends = (undefined && undefined.__extends) || (function () { + var extendStatics = function (d, b) { + extendStatics = Object.setPrototypeOf || + ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || + function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; + return extendStatics(d, b); + }; + return function (d, b) { + if (typeof b !== "function" && b !== null) + throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); + extendStatics(d, b); + function __() { this.constructor = d; } + d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); + }; +})(); +var __assign = (undefined && undefined.__assign) || function () { + __assign = Object.assign || function(t) { + for (var s, i = 1, n = arguments.length; i < n; i++) { + s = arguments[i]; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) + t[p] = s[p]; + } + return t; + }; + return __assign.apply(this, arguments); +}; +var IntersectionObserverAdmin = /** @class */ (function (_super) { + __extends(IntersectionObserverAdmin, _super); + function IntersectionObserverAdmin() { + var _this = _super.call(this) || this; + _this.elementRegistry = new Registry(); + return _this; + } + /** + * Adds element to observe via IntersectionObserver and stores element + relevant callbacks and observer options in static + * administrator for lookup in the future + * + * @method observe + * @param {HTMLElement | Window} element + * @param {Object} options + * @public + */ + IntersectionObserverAdmin.prototype.observe = function (element, options) { + if (options === void 0) { options = {}; } + if (!element) { + return; + } + this.elementRegistry.addElement(element, __assign({}, options)); + this.setupObserver(element, __assign({}, options)); + }; + /** + * Unobserve target element and remove element from static admin + * + * @method unobserve + * @param {HTMLElement|Window} target + * @param {Object} options + * @public + */ + IntersectionObserverAdmin.prototype.unobserve = function (target, options) { + var matchingRootEntry = this.findMatchingRootEntry(options); + if (matchingRootEntry) { + var intersectionObserver = matchingRootEntry.intersectionObserver; + intersectionObserver.unobserve(target); + } + }; + /** + * register event to handle when intersection observer detects enter + * + * @method addEnterCallback + * @public + */ + IntersectionObserverAdmin.prototype.addEnterCallback = function (element, callback) { + this.addCallback(CallbackType.enter, element, callback); + }; + /** + * register event to handle when intersection observer detects exit + * + * @method addExitCallback + * @public + */ + IntersectionObserverAdmin.prototype.addExitCallback = function (element, callback) { + this.addCallback(CallbackType.exit, element, callback); + }; + /** + * retrieve registered callback and call with data + * + * @method dispatchEnterCallback + * @public + */ + IntersectionObserverAdmin.prototype.dispatchEnterCallback = function (element, entry) { + this.dispatchCallback(CallbackType.enter, element, entry); + }; + /** + * retrieve registered callback and call with data on exit + * + * @method dispatchExitCallback + * @public + */ + IntersectionObserverAdmin.prototype.dispatchExitCallback = function (element, entry) { + this.dispatchCallback(CallbackType.exit, element, entry); + }; + /** + * cleanup data structures and unobserve elements + * + * @method destroy + * @public + */ + IntersectionObserverAdmin.prototype.destroy = function () { + this.elementRegistry.destroyRegistry(); + }; + /** + * use function composition to curry options + * + * @method setupOnIntersection + * @param {Object} options + */ + IntersectionObserverAdmin.prototype.setupOnIntersection = function (options) { + var _this = this; + return function (ioEntries) { + return _this.onIntersection(options, ioEntries); + }; + }; + IntersectionObserverAdmin.prototype.setupObserver = function (element, options) { + var _a; + var _b = options.root, root = _b === void 0 ? window : _b; + // First - find shared root element (window or target HTMLElement) + // this root is responsible for coordinating it's set of elements + var potentialRootMatch = this.findRootFromRegistry(root); + // Second - if there is a matching root, see if an existing entry with the same options + // regardless of sort order. This is a bit of work + var matchingEntryForRoot; + if (potentialRootMatch) { + matchingEntryForRoot = this.determineMatchingElements(options, potentialRootMatch); + } + // next add found entry to elements and call observer if applicable + if (matchingEntryForRoot) { + var elements = matchingEntryForRoot.elements, intersectionObserver = matchingEntryForRoot.intersectionObserver; + elements.push(element); + if (intersectionObserver) { + intersectionObserver.observe(element); + } + } + else { + // otherwise start observing this element if applicable + // watcher is an instance that has an observe method + var intersectionObserver = this.newObserver(element, options); + var observerEntry = { + elements: [element], + intersectionObserver: intersectionObserver, + options: options + }; + // and add entry to WeakMap under a root element + // with watcher so we can use it later on + var stringifiedOptions = this.stringifyOptions(options); + if (potentialRootMatch) { + // if share same root and need to add new entry to root match + // not functional but :shrug + potentialRootMatch[stringifiedOptions] = observerEntry; + } + else { + // no root exists, so add to WeakMap + this.elementRegistry.addElement(root, (_a = {}, + _a[stringifiedOptions] = observerEntry, + _a)); + } + } + }; + IntersectionObserverAdmin.prototype.newObserver = function (element, options) { + // No matching entry for root in static admin, thus create new IntersectionObserver instance + var root = options.root, rootMargin = options.rootMargin, threshold = options.threshold; + var newIO = new IntersectionObserver(this.setupOnIntersection(options).bind(this), { root: root, rootMargin: rootMargin, threshold: threshold }); + newIO.observe(element); + return newIO; + }; + /** + * IntersectionObserver callback when element is intersecting viewport + * either when `isIntersecting` changes or `intersectionRadio` crosses on of the + * configured `threshold`s. + * Exit callback occurs eagerly (when element is initially out of scope) + * See https://stackoverflow.com/questions/53214116/intersectionobserver-callback-firing-immediately-on-page-load/53385264#53385264 + * + * @method onIntersection + * @param {Object} options + * @param {Array} ioEntries + * @private + */ + IntersectionObserverAdmin.prototype.onIntersection = function (options, ioEntries) { + var _this = this; + ioEntries.forEach(function (entry) { + var isIntersecting = entry.isIntersecting, intersectionRatio = entry.intersectionRatio; + var threshold = options.threshold || 0; + if (Array.isArray(threshold)) { + threshold = threshold[threshold.length - 1]; + } + // then find entry's callback in static administration + var matchingRootEntry = _this.findMatchingRootEntry(options); + // first determine if entry intersecting + if (isIntersecting || intersectionRatio > threshold) { + if (matchingRootEntry) { + matchingRootEntry.elements.some(function (element) { + if (element && element === entry.target) { + _this.dispatchEnterCallback(element, entry); + return true; + } + return false; + }); + } + } + else { + if (matchingRootEntry) { + matchingRootEntry.elements.some(function (element) { + if (element && element === entry.target) { + _this.dispatchExitCallback(element, entry); + return true; + } + return false; + }); + } + } + }); + }; + /** + * { root: { stringifiedOptions: { observer, elements: []...] } } + * @method findRootFromRegistry + * @param {HTMLElement|Window} root + * @private + * @return {Object} of elements that share same root + */ + IntersectionObserverAdmin.prototype.findRootFromRegistry = function (root) { + if (this.elementRegistry) { + return this.elementRegistry.getElement(root); + } + }; + /** + * We don't care about options key order because we already added + * to the static administrator + * + * @method findMatchingRootEntry + * @param {Object} options + * @return {Object} entry with elements and other options + */ + IntersectionObserverAdmin.prototype.findMatchingRootEntry = function (options) { + var _a = options.root, root = _a === void 0 ? window : _a; + var matchingRoot = this.findRootFromRegistry(root); + if (matchingRoot) { + var stringifiedOptions = this.stringifyOptions(options); + return matchingRoot[stringifiedOptions]; + } + }; + /** + * Determine if existing elements for a given root based on passed in options + * regardless of sort order of keys + * + * @method determineMatchingElements + * @param {Object} options + * @param {Object} potentialRootMatch e.g. { stringifiedOptions: { elements: [], ... }, stringifiedOptions: { elements: [], ... }} + * @private + * @return {Object} containing array of elements and other meta + */ + IntersectionObserverAdmin.prototype.determineMatchingElements = function (options, potentialRootMatch) { + var _this = this; + var matchingStringifiedOptions = Object.keys(potentialRootMatch).filter(function (key) { + var comparableOptions = potentialRootMatch[key].options; + return _this.areOptionsSame(options, comparableOptions); + })[0]; + return potentialRootMatch[matchingStringifiedOptions]; + }; + /** + * recursive method to test primitive string, number, null, etc and complex + * object equality. + * + * @method areOptionsSame + * @param {any} a + * @param {any} b + * @private + * @return {boolean} + */ + IntersectionObserverAdmin.prototype.areOptionsSame = function (a, b) { + if (a === b) { + return true; + } + // simple comparison + var type1 = Object.prototype.toString.call(a); + var type2 = Object.prototype.toString.call(b); + if (type1 !== type2) { + return false; + } + else if (type1 !== '[object Object]' && type2 !== '[object Object]') { + return a === b; + } + if (a && b && typeof a === 'object' && typeof b === 'object') { + // complex comparison for only type of [object Object] + for (var key in a) { + if (Object.prototype.hasOwnProperty.call(a, key)) { + // recursion to check nested + if (this.areOptionsSame(a[key], b[key]) === false) { + return false; + } + } + } + } + // if nothing failed + return true; + }; + /** + * Stringify options for use as a key. + * Excludes options.root so that the resulting key is stable + * + * @param {Object} options + * @private + * @return {String} + */ + IntersectionObserverAdmin.prototype.stringifyOptions = function (options) { + var root = options.root; + var replacer = function (key, value) { + if (key === 'root' && root) { + var classList = Array.prototype.slice.call(root.classList); + var classToken = classList.reduce(function (acc, item) { + return (acc += item); + }, ''); + var id = root.id; + return "".concat(id, "-").concat(classToken); + } + return value; + }; + return JSON.stringify(options, replacer); + }; + return IntersectionObserverAdmin; +}(Notifications)); + +export default IntersectionObserverAdmin; +//# sourceMappingURL=intersection-observer-admin.es5.js.map 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); + } + }, + }; +} diff --git a/shared/components/src/components/Artwork/Artwork.svelte b/shared/components/src/components/Artwork/Artwork.svelte new file mode 100644 index 0000000..c661947 --- /dev/null +++ b/shared/components/src/components/Artwork/Artwork.svelte @@ -0,0 +1,565 @@ +<script lang="ts"> + import type { SvelteComponent } from 'svelte'; + import { onMount } from 'svelte'; + import { makeSafeTick } from '@amp/web-app-components/src/utils/makeSafeTick'; + import type { Readable } from 'svelte/store'; + import LoaderSelector, { + LOADER_TYPE, + } from '@amp/web-app-components/src/components/Artwork/loaders/LoaderSelector.svelte'; + import { + getShelfAspectRatioContext, + hasShelfAspectRatioContext, + } from '@amp/web-app-components/src/utils/shelfAspectRatio'; + import { FILE_TO_MIME_TYPE, DEFAULT_FILE_TYPE } from './constants'; + import type { Artwork, ImageSettings, Profile, ChinConfig } from './types'; + import { getAspectRatio, getImageTagWidthHeight } from './utils/artProfile'; + import { getPreconnectTracker } from './utils/preconnect'; + import { buildSourceSet, getImageSizes } from './utils/srcset'; + import { deriveBackgroundColor } from './utils/validateBackground'; + + const preconnectTracker = getPreconnectTracker(); + + /** + * artwork object + * @type {{ template: string, width: number, height: number, backgroundColor: string }} Artwork + */ + export let artwork: Artwork; + /** + * alt tag to use on image. + */ + export let alt: string = ''; + /** + * id to use on image. + * @type {string} + */ + export let id: string | undefined = undefined; + /** + * Profiles are required to determine the optimal image to render for given viewports. + * @type {Profile | string} + */ + export let profile: Profile | string; + /** + * k/v map of settings that don't depend on viewport size. + * @type {ImageSettings} + */ + export let imageSettings: ImageSettings = {}; + /** + * Apply rounded secondary corner styles to top of artwork image + * @type {boolean} + */ + export let topRoundedSecondary: boolean = false; + /** + * Whether to lazy load the image. + * Set this to false if this image is expected to be the LCP. + */ + export let lazyLoad: boolean = true; + /** + * Sets the `fetchpriority` attribute on the image. + * Set this to 'high' if this image is expected to be the LCP. + */ + export let fetchPriority: 'high' | 'auto' | 'low' = 'auto'; + /** + * Turning off container styles allows for a custom wrapper to be used to provide different + * styling when an artwork is used outside of a lockup or in a different context. + * @type {boolean} + */ + export let useContainerStyle: boolean = true; + /** + * Option to disable CSS anchoring for shelf chevron. + * Useful to isolate anchor when there are multiple images in a single lockup. + * @type {boolean} + */ + export let noShelfChevronAnchor: boolean = false; + + /** + * Configuration object for chin effects including height and style. + * Used primarily by TV app for adding visual effects below the main artwork. + * @type {ChinConfig} + */ + export let chinConfig: ChinConfig | undefined = undefined; + + export let forceFullWidth: boolean = true; + + /** + * Option to disable image from being auto-centered + * in its container. Only relevant for non-square + * images. + */ + export let disableAutoCenter = false; + + /** + * `isDecorative` indicates if an image is decoration. + * Decoaration images should be attributed a presentation role (role=presentation) to avoid an oververbose auditory user experience. + * By default, it is set to false if an alt attribute is provided. + * See https://www.w3.org/WAI/tutorials/images/decorative/ + * @type {boolean} + */ + export let isDecorative: boolean = !!!alt; + + /** + * Allows artwork to be rendered without a border, regardless of it's background color or transparency. + */ + export let withoutBorder: boolean = false; + + let localShelfAspectRatioStore: Readable<string> | null = null; + + if (hasShelfAspectRatioContext()) { + const { addProfile, shelfAspectRatio } = getShelfAspectRatioContext(); + addProfile(profile); + localShelfAspectRatioStore = shelfAspectRatio; + } + + $: template = artwork && artwork.template; + + $: imageIsLoading = !!template; // start in loading state when template is available + $: thereWasAnError = !artwork; // start in clean error state unless there's no artwork passed + + $: backgroundColor = artwork?.backgroundColor; + + $: ({ fileType = DEFAULT_FILE_TYPE } = imageSettings); + + $: isBackgroundTransparent = + imageSettings?.hasTransparentBackground ?? false; + + $: validBackgroundColor = isBackgroundTransparent + ? 'transparent' + : deriveBackgroundColor(backgroundColor); + + $: srcset = + artwork && buildSourceSet(artwork, imageSettings, profile, chinConfig); + $: webpSourceSet = + artwork && + buildSourceSet( + artwork, + Object.assign({}, imageSettings, { fileType: 'webp' }), + profile, + chinConfig, + ); + $: aspectRatio = getAspectRatio(profile); + $: imageTagSizeObj = getImageTagWidthHeight(profile); + + // Calculate effective aspect ratio accounting for chin height + $: effectiveAspectRatio = (() => { + const chinHeightValue = chinConfig?.height ?? 0; + if (chinHeightValue === 0 || aspectRatio === null) { + return aspectRatio; + } + + // Get the base dimensions from the profile + const baseHeight = imageTagSizeObj.height; + const baseWidth = imageTagSizeObj.width; + + // Calculate new aspect ratio with chin height added + const newHeight = baseHeight + chinHeightValue; + return baseWidth / newHeight; + })(); + + // NOTE: We intentionally set opacity to 1 in SSR so that images will load + // in before the JS loads. + $: opacity = `${imageIsLoading && typeof window !== 'undefined' ? 0 : 1}`; + // And similarly, we force <NoLoader> so that the image markup is emitted + $: loaderType = + lazyLoad && typeof window !== 'undefined' + ? LOADER_TYPE.LAZY + : LOADER_TYPE.NONE; + + $: sizes = getImageSizes(profile, artwork?.width); + + $: wrapperStyle = (() => { + // remove the joe color background to prevent + // parts of it from bleeding through artwork + const background = + ($$slots['placeholder-component'] && thereWasAnError) || + hasTransitionInEnded || + isBackgroundTransparent + ? 'transparent' + : `${validBackgroundColor}`; + + // if backgroundColor data is unavailable, do not insert inline background styles + // (--artwork-bg-color & --placeholder-bg-color) - to allow joe color fallback + const artworkBGColor = validBackgroundColor + ? `--artwork-bg-color: ${validBackgroundColor};` + : ''; + const placeholderBGColor = background + ? `--placeholder-bg-color: ${background};` + : ''; + + return ` + ${artworkBGColor} + --aspect-ratio: ${ + effectiveAspectRatio !== null ? effectiveAspectRatio : 1 + }; + ${placeholderBGColor} + `; + })(); + + $: { + preconnectTracker?.trackUrl(template); + } + + /** + * false if image natural aspect ratio is not equal to profile + * + * @see {onImageLoad} + */ + let aspectRatioMatchesProfile = true; + + $: hasDominantShelfAspectRatio = + localShelfAspectRatioStore !== null && + $localShelfAspectRatioStore !== null; + + // Should apply joe color BG if image natural aspect ratio doesn't match shelfAspectRatio + $: shouldOverrideBG = (() => { + let overrideBG = false; + if (localShelfAspectRatioStore !== null) { + const shelfAspectRatio = parseFloat($localShelfAspectRatioStore); + if (!isNaN(shelfAspectRatio)) { + const roundedShelfAspectRatio = + Math.round(shelfAspectRatio * 100) / 100; + const roundedAspectRatio = + Math.round(effectiveAspectRatio * 100) / 100; + if (roundedShelfAspectRatio !== roundedAspectRatio) { + overrideBG = true; + } + } + } else if (!aspectRatioMatchesProfile) { + overrideBG = true; + } + return overrideBG; + })(); + + const onImageLoad = (e: Event) => { + const img = e.target as HTMLImageElement; + + if (img.naturalHeight !== 0 && img.naturalWidth !== 0) { + const actualAspectRatio = + Math.round((img.naturalWidth / img.naturalHeight) * 100) / 100; + const roundedEstimate = + Math.round(effectiveAspectRatio * 100) / 100; + + if ( + actualAspectRatio !== roundedEstimate && + Math.abs( + (actualAspectRatio - roundedEstimate) / + ((actualAspectRatio + roundedEstimate) / 2), + ) > 0.1 + ) { + aspectRatioMatchesProfile = false; + } + } + imageIsLoading = false; + }; + + let hasTransitionInEnded = false; + const onTransitionEnd = (e: TransitionEvent) => { + const img = e.target as HTMLElement; + const opacityValue = parseFloat(img.style.opacity); + + if (opacityValue === 1) { + hasTransitionInEnded = true; + } else { + hasTransitionInEnded = false; + } + }; + + const onImageError = () => { + thereWasAnError = true; + imageIsLoading = false; + }; + + let loaderComponent: SvelteComponent; + let artworkComponent: HTMLElement; + + const safeTick = makeSafeTick(); + + onMount(async () => { + await safeTick(async (tick) => { + await tick(); + loaderComponent.onSlotMount(artworkComponent); + }); + }); + + const getImageOrientation = (aspectRatio: number) => { + let orientation: 'square' | 'landscape' | 'portrait'; + if (aspectRatio === 1) { + orientation = 'square'; + } else if (aspectRatio > 1) { + orientation = 'landscape'; + } else { + orientation = 'portrait'; + } + return orientation; + }; +</script> + +<div + data-testid="artwork-component" + {id} + class={`artwork-component artwork-component--aspect-ratio artwork-component--orientation-${getImageOrientation( + effectiveAspectRatio, + )}`} + class:container-style={useContainerStyle} + class:artwork-component--downloaded={!imageIsLoading && + hasTransitionInEnded} + class:artwork-component--error={thereWasAnError} + class:artwork-component--fullwidth={forceFullWidth} + class:artwork-component--top-rounded-secondary={topRoundedSecondary} + class:artwork-component--auto-center={!disableAutoCenter && + (hasDominantShelfAspectRatio || !aspectRatioMatchesProfile)} + class:artwork-component--bg-override={shouldOverrideBG} + class:artwork-component--has-borders={!isBackgroundTransparent && + !withoutBorder} + class:artwork-component--no-anchor={noShelfChevronAnchor} + style={wrapperStyle} + on:transitionend={onTransitionEnd} + bind:this={artworkComponent} +> + {#if imageIsLoading && $$slots['loading-component']} + <div + class="artwork-component__contents" + data-testid="artwork-component__loading" + > + <slot name="loading-component" /> + </div> + {:else if thereWasAnError && $$slots['placeholder-component']} + <div + class="artwork-component__contents" + data-testid="artwork-component__placeholder" + > + <slot name="placeholder-component" /> + </div> + {/if} + <LoaderSelector {loaderType} bind:this={loaderComponent} let:isVisible> + {#if !thereWasAnError && isVisible} + <picture> + {#if webpSourceSet} + <source + {sizes} + srcset={webpSourceSet} + type={FILE_TO_MIME_TYPE.webp} + /> + {/if} + <source {sizes} {srcset} type={FILE_TO_MIME_TYPE[fileType]} /> + <img + {alt} + class="artwork-component__contents artwork-component__image" + loading={lazyLoad ? 'lazy' : null} + style:opacity + src="/assets/artwork/1x1.gif" + role={isDecorative ? 'presentation' : null} + decoding="async" + width={`${imageTagSizeObj.width}`} + height={`${ + imageTagSizeObj.height + (chinConfig?.height ?? 0) + }`} + fetchpriority={fetchPriority} + on:load={onImageLoad} + on:error={onImageError} + /> + </picture> + {/if} + </LoaderSelector> +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'amp/stylekit/core/colors' as *; + @use 'amp/stylekit/core/mixins/browser-targets' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/app/core/mixins/after-shadow' as *; + @use '@amp/web-shared-styles/app/core/colors' as *; + @use './style/ratio-based-artwork-box.scss' as *; + + // container style design: https://pd-hi.apple.com/viewvc/Common/Modules/macOS/-Cross%20Product/_macOS%20-%20Content%20Container%20Treatment.png?revision=54684&pathrev=57428 + // TODO: rdar://79348133 (Bring in copy + pasted variables into StyleKit) + .container-style { + border-radius: var( + --global-border-radius-medium, + #{$global-border-radius-medium} + ); + + &::after { + @include after-shadow; + } + } + + .artwork-component { + width: var(--artwork-override-width, 100%); + height: var(--artwork-override-height, auto); + max-width: var(--artwork-override-max-width, none); + min-width: var(--artwork-override-min-width, 0); + min-height: var(--artwork-override-min-height, 0); + max-height: var(--artwork-override-max-height, none); + border-radius: inherit; + box-sizing: border-box; + contain: content; + overflow: hidden; + position: relative; + background-color: var( + --override-placeholder-bg-color, + var(--placeholder-bg-color, var(--genericJoeColor)) + ); + z-index: var(--z-default); + + &.artwork-component--has-borders { + &::after { + @include after-shadow; + } + } + + &.artwork-component--auto-center { + @include ratio-based-artwork-box; + + &.artwork-component--bg-override { + background-color: var(--artwork-bg-color); + } + } + } + + // Artwork with rounded-secondary border-radius on top corners + .artwork-component--top-rounded-secondary { + // Required to keep lockups/chins aligned with the same height, when 2-line clamps are visible. + flex-grow: 0; + // Applying `border-radius` and `overflow: hidden;` to prevent image/chin subpixel width mismatch + // prettier-ignore + border-radius: var(--global-border-radius-large, #{$global-border-radius-large}) var(--global-border-radius-large, #{$global-border-radius-large}) 0 0; + overflow: hidden; + + &, + &::after { + // prettier-ignore + border-radius: var(--global-border-radius-large, #{$global-border-radius-large}) var(--global-border-radius-large, #{$global-border-radius-large}) 0 0; + } + + @media (--target-desktop) { + &::after { + --global-transition-property: background-color; + transition: var(--global-transition, opacity 0.1s ease-in); + + .horizontal-poster-lockup:hover &, + .horizontal-poster-lockup:focus &, + .horizontal-poster-lockup:focus-within & { + background-color: var(--lockupHoverBGColor); + } + } + } + + // + // Webkit Box Reflect chins + // + @supports (-webkit-box-reflect: inherit) { + -webkit-box-reflect: below; + overflow: visible; + + &::after { + box-shadow: none; + } + } + } + + //Revisit for potential clean up + .artwork-component__contents { + border-radius: inherit; + transition: var(--global-transition, opacity 0.1s ease-in); + } + + .artwork-component__image { + height: var(--artwork-override-height, auto); + width: var(--artwork-override-width, 100%); + max-width: var(--artwork-override-max-width, none); + min-width: var(--artwork-override-min-width, 0); + min-height: var(--artwork-override-min-height, 0); + max-height: var(--artwork-override-max-height, none); + display: block; + object-fit: var(--artwork-override-object-fit, fill); + object-position: var(--artwork-override-object-position, center); + } + + .artwork-component:not(.artwork-component--downloaded), + // If image doesn't download/render, on error, show JoeColor in placeholders. + // .artwork-component--feature-recommended, + .artwork-component--error { + background-color: var( + --override-placeholder-bg-color, + var(--placeholder-bg-color, var(--genericJoeColor)) + ); + // for generic joe color - it provides light/dark mode. + &[style*='#ebebeb'] { + @media (prefers-color-scheme: dark) { + // Force Dark Generic joeColor for dark mode + background-color: swatch(genericJoeColor, dark); + } + } + } + + // Dynamic aspect ratios + // Create placeholders with aspect-ratio derived from `artwork-profiles.js` + // https://github.com/thierryk/aspect-ratio-via-css/tree/master/aspect-ratio-via-class-selector + // + // Apply aspect ratio to `1x1` `src` placeholders. Once downloaded, the placeholder aspect ratio is no longer needed. + // + .artwork-component--aspect-ratio:not(.artwork-component--downloaded), + // If image doesn't download/render, on error, show aspect-ratio placeholders instead. + .artwork-component--error { + // Placeholder `src` may have different aspect ratio. Hide overflow in that case. + overflow: hidden; + + &::before, + &::after { + content: ''; + display: block; + // prettier-ignore + padding-bottom: calc(100% / var(--shelf-aspect-ratio, var(--aspect-ratio))); + // Prevent distortion of overlaid border from additional padding + box-sizing: border-box; + } + + &::after { + position: absolute; + // No `min-height: 100%` on border overlay when generating aspect-ratio placeholder. + min-height: 0; + } + + // `img` may not always be the first-child. Can be an svg or another container. + > :global(:first-child), + > :global(noscript) > :global(:first-child) { + position: absolute; + width: var(--artwork-override-width, 100%); + height: var(--artwork-override-height, 100%); + max-width: var(--artwork-override-max-width, none); + min-width: var(--artwork-override-min-width, 0); + min-height: var(--artwork-override-min-height, 0); + max-height: var(--artwork-override-max-height, none); + top: 50%; + left: 50%; // RTL not needed + transform: translateY(-50%) translateX(-50%); // RTL not needed + z-index: var(--z-default); + } + + > :global(img), + > :global(noscript) > :global(img) { + height: auto; + min-height: var(--artwork-override-min-height, 0); + } + } + + // Full width (`forceFullWidth`) sizing is default, since most artwork are in responsive lockups. + // Avoid using `--artwork-override-width` or `--artwork-override-height` with `forceFullWidth` property enabled. + .artwork-component--fullwidth { + &, + > :global(noscript) { + width: 100%; + } + + > :global(noscript > picture .artwork-component__image) { + width: 100%; + height: auto; + + &::after { + width: 100%; + display: block; + content: ''; + } + } + } +</style> diff --git a/shared/components/src/components/Artwork/constants.ts b/shared/components/src/components/Artwork/constants.ts new file mode 100644 index 0000000..7fd6564 --- /dev/null +++ b/shared/components/src/components/Artwork/constants.ts @@ -0,0 +1,227 @@ +/** + * COPIED FROM: https://github.pie.apple.com/amp-ui/ember-ui-media-artwork/blob/main/addon/utils/srcset.js + * and converted public functions to TypeScript + */ + +import type { CropCode, FileExtension } from './types'; + +const baseWidthHeightRegex = '({w}|[0-9]+)x({h}|[0-9]+)'; +const baseFileTypeRegex = '{f}|([a-zA-Z]{3,4})'; +// ([A-z]{1,6}\\.[\\w]{1,8}) - copy pasta of the regex used on the backend for EffectIds +// https://github.pie.apple.com/amp/ai-imageservice/blob/84abff624a2da5b45bdf91c5bcd87b6708ad12ae/is-foundation/src/main/java/com/apple/imageservice/foundation/program/EffectId.java#L22 +const baseEffectCropCode = '[A-z]{1,6}\\.[\\w]{1,8}'; + +export const EMBEDDED_CROP_CODE_REGEX = new RegExp( + `^${baseWidthHeightRegex}([a-zA-Z]+)`, +); +export const FILE_TYPE_REGEX = new RegExp(baseFileTypeRegex); +// TODO: rdar://97913309 (JMOTW: Artwork: Quality Param regex injects quality placeholder when no hardcoded quality param exists) +export const QUALITY_PARAM_REGEX = /(-[0-9]+)?\.(\{f\}|[A-z]{2,4})$/; + +export const EFFECT_ID_REGEX = new RegExp( + `^${baseWidthHeightRegex}(${baseEffectCropCode})\\.(${baseFileTypeRegex})`, +); + +// non capturing to ignore either effect cc or regular cc +export const REPLACE_CROP_CODE_REGEX = new RegExp( + `${baseWidthHeightRegex}(?:${baseEffectCropCode}|[a-z]{1,2})\\.(${baseFileTypeRegex})`, +); + +export const DEFAULT_QUALITY = 60; + +// Specific viewport widths that don't align cleanly with media query breakpoints +export const LN_TALL_BREAKPOINT_WIDTH = 729; +export const ARTIST_VIDEO_TALL_BREAKPOINT_WIDTH = 674; + +/** + * Instead of reading pixel density (which is different in fastboot and browser), + * we'll bake in support for 1x and 2x pixel densities. This means a larger + * set of sources, but it means we don't have to recalculate and potentially double + * download images. + * @export const PIXEL_DENSITIES + * @private + */ +export const PIXEL_DENSITIES = [1, 2]; + +/** + * default cropcode if none is provided + */ +export const DEFAULT_CROP: CropCode = 'fa'; + +/** + * default fileType if none is provided + */ +export const DEFAULT_FILE_TYPE: FileExtension = 'jpg'; + +export const ASPECT_RATIOS = { + HD: 16 / 9, + ONE_THIRD: 3 / 1, + ONE: 1, + THREE_QUARTERS: 3 / 4, + UBER: 4, + HD_ASPECT_RATIO: 16 / 9, + VIDEO_LIST: 7 / 4, + VIDEO_TALL: 9 / 16, + HERO: 68 / 39, + SUPER_HERO_WIDE: 22 / 9, + WELCOME: 466 / 293, + EDITORIAL_DEFAULT: 68 / 39, +} as const; + +export const FILE_EXTENSIONS = ['jpg', 'webp', 'png'] as const; + +export const FILE_TO_MIME_TYPE = { + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', +} as const; + +// https://confluence.sd.apple.com/pages/viewpage.action?spaceKey=AMPDSCE&title=Crop+Code+Master+List +export const ALL_CROP_CODES = [ + '{c}', + 'at', + 'ac', + 'bb', + 'bw', + 'bf', + 'br', + 'h', + 'w', + 'cc', + 'cx', + 'ca', + 'cb', + 'cw', + 'cu', + 'cy', + 'cv', + 'rc', + 'rs', + 'sr', + 'ss', + 'fa', + 'fb', + 'fc', + 'fd', + 'fe', + 'ff', + 'fg', + 'fh', + 'fi', + 'fj', + 'fk', + 'fl', + 'fm', + 'fn', + 'fo', + 'fp', + 'fq', + 'fr', + 'fs', + 'ft', + 'fu', + 'fv', + 'fw', + 'fx', + 'fy', + 'ea', + 'eb', + 'ec', + 'ed', + 'ee', + 'ef', + 'eg', + 'eh', + 'ei', + 'ej', + 'ek', + 'el', + 'em', + 'en', + 'eo', + 'ep', + 'eq', + 'er', + 'es', + 'et', + 'eu', + 'ev', + 'ew', + 'ex', + 'ey', + 'ez', + 'ga', + 'gb', + 'gc', + 'lg', + 'lw', + 'lc', + 'ld', + 'la', + 'lb', + 'lt', + 'lh', + 'mv', + 'mw', + 'mf', + 'nr', + 'sy', + 'sx', + 'sz', + 'sa', + 'sb', + 'sc', + 'sd', + 'se', + 'sf', + 'sg', + 'sh', + 'si', + 'sj', + 'sk', + 'va', + 'vb', + 'vc', + 'vd', + 've', + 'vf', + 'vi', + 'vj', + 'vl', + 'wp', + 'wa', + 'wb', + 'wc', + 'wd', + 'we', + 'wf', + 'wg', + 'wv', + 'wx', + 'wy', + 'wz', + 'ta', + 'tb', + 'tc', + 'td', + 'oa', + 'ob', + 'oc', + 'od', + 'oe', + 'of', + 'og', + 'oh', + 'Sports.TVAGPW01', + 'Sports.SS1x101', + 'PH.WSAHS01', +] as const; + +const isLoadingAvailable = + typeof HTMLImageElement !== 'undefined' && + 'loading' in HTMLImageElement.prototype; + +export const shouldUseLazyLoader = + typeof window !== 'undefined' && + window.IntersectionObserver && + !isLoadingAvailable; diff --git a/shared/components/src/components/Artwork/loaders/LazyLoader.svelte b/shared/components/src/components/Artwork/loaders/LazyLoader.svelte new file mode 100644 index 0000000..1857c7b --- /dev/null +++ b/shared/components/src/components/Artwork/loaders/LazyLoader.svelte @@ -0,0 +1,89 @@ +<!-- + LazyLoader Component + This component provides loading="lazy" + functionality for browsers that do not support it. + It uses Intersection Observers to evaluate + if an image needs to be loaded. + + DO NOT USE DIRECTLY use LoaderSelector +--> +<script context="module" lang="ts"> + import { get } from 'svelte/store'; + import { shouldUseLazyLoader } from '@amp/web-app-components/src/components/Artwork/constants'; + import { createArtworkLoaderStore } from '@amp/web-app-components/src/components/Artwork/stores/artworkLoader'; + import type { ArtworkLoaderStore } from '@amp/web-app-components/src/components/Artwork/stores/artworkLoader'; + import { getRafQueue } from '@amp/web-app-components/src/utils/rafQueue'; + + const rafQueue = getRafQueue(); + + let artworkLookupTable: ArtworkLoaderStore | null = null; + let observer: IntersectionObserver | null = null; + + const setupObserver = () => { + let options = { + root: null, // go off viewport + rootMargin: '0px', + threshold: 0.0, + }; + + return new IntersectionObserver((entries) => { + entries.forEach((item) => { + rafQueue.add(() => { + const storeValue = get(artworkLookupTable); + const isItemAlreadyVisible = storeValue.get(item.target); + if (!isItemAlreadyVisible) { + artworkLookupTable.addEntry( + item.target, + item.isIntersecting, + ); + } + }); + }); + }, options); + }; + if (shouldUseLazyLoader) { + observer = setupObserver(); + artworkLookupTable = createArtworkLoaderStore(); + } +</script> + +<script lang="ts"> + import { onDestroy } from 'svelte'; + + let isSubscribed = false; + + let container: Element; + let isVisible: boolean = false; + let unsubscribeToStore: () => void = () => {}; + + const cleanup = () => { + unsubscribeToStore(); + observer.unobserve(container); + artworkLookupTable.cleanupEntry(container); + }; + + $: { + if (isVisible && isSubscribed) { + cleanup(); + isSubscribed = false; + } + } + + export function onSlotMount(artworkComponent: Element) { + container = artworkComponent; + isSubscribed = true; + observer.observe(container); + + unsubscribeToStore = artworkLookupTable.subscribe((map) => { + isVisible = map.get(container); + }); + } + + onDestroy(() => { + if (isSubscribed) { + cleanup(); + } + }); +</script> + +<slot {isVisible} /> diff --git a/shared/components/src/components/Artwork/loaders/LoaderSelector.svelte b/shared/components/src/components/Artwork/loaders/LoaderSelector.svelte new file mode 100644 index 0000000..1d97814 --- /dev/null +++ b/shared/components/src/components/Artwork/loaders/LoaderSelector.svelte @@ -0,0 +1,38 @@ +<script context="module" lang="ts"> + export const LOADER_TYPE = { + LAZY: 'LAZY', + NONE: 'NONE', + } as const; +</script> + +<script lang="ts"> + import LazyLoader from '@amp/web-app-components/src/components/Artwork/loaders/LazyLoader.svelte'; + import NoLoader from '@amp/web-app-components/src/components/Artwork/loaders/NoLoader.svelte'; + import { shouldUseLazyLoader } from '@amp/web-app-components/src/components/Artwork/constants'; + import type { ValueOf } from '@amp/web-app-components/src/types'; + import type { SvelteComponent } from 'svelte'; + + type LoaderOptions = ValueOf<typeof LOADER_TYPE>; + + export let loaderType: LoaderOptions = LOADER_TYPE.LAZY; + + interface LoaderComponent extends SvelteComponent { + onSlotMount: (component: Element) => void; + } + + let currentComponent: LoaderComponent; + + export function onSlotMount(component: Element) { + currentComponent.onSlotMount(component); + } +</script> + +{#if loaderType === LOADER_TYPE.LAZY && shouldUseLazyLoader} + <LazyLoader bind:this={currentComponent} let:isVisible + ><slot {isVisible} /></LazyLoader + > +{:else} + <NoLoader bind:this={currentComponent} let:isVisible + ><slot {isVisible} /></NoLoader + > +{/if} diff --git a/shared/components/src/components/Artwork/loaders/NoLoader.svelte b/shared/components/src/components/Artwork/loaders/NoLoader.svelte new file mode 100644 index 0000000..b453e03 --- /dev/null +++ b/shared/components/src/components/Artwork/loaders/NoLoader.svelte @@ -0,0 +1,20 @@ +<!-- + NoLoader Component + This component should be used when loading="lazy" + is supported. + + DO NOT USE DIRECTLY use LoaderSelector +--> +<script lang="ts"> + let mounted = false; + + export function onSlotMount(_artworkComponent: Element) { + mounted = true; + } + + const ssr = typeof window === 'undefined'; + + $: isVisible = mounted || ssr; +</script> + +<slot {isVisible} /> diff --git a/shared/components/src/components/Artwork/stores/artworkLoader.ts b/shared/components/src/components/Artwork/stores/artworkLoader.ts new file mode 100644 index 0000000..0d7116a --- /dev/null +++ b/shared/components/src/components/Artwork/stores/artworkLoader.ts @@ -0,0 +1,30 @@ +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export type ArtworkLoaderStore = { + subscribe: Writable<WeakMap<Element, boolean>>['subscribe']; + addEntry: (entry: Element, isVisible: boolean) => void; + cleanupEntry: (entry: Element) => void; +}; + +export function createArtworkLoaderStore(): ArtworkLoaderStore { + const value = new WeakMap(); + const { subscribe, update } = writable(value); + + return { + subscribe, + addEntry: (entry: Element, isVisible: boolean) => { + update((map) => { + map.set(entry, isVisible); + return map; + }); + }, + + cleanupEntry: (entry: Element) => { + update((map) => { + map.delete(entry); + return map; + }); + }, + }; +} diff --git a/shared/components/src/components/Artwork/utils/artProfile.ts b/shared/components/src/components/Artwork/utils/artProfile.ts new file mode 100644 index 0000000..fccd4e5 --- /dev/null +++ b/shared/components/src/components/Artwork/utils/artProfile.ts @@ -0,0 +1,77 @@ +import type { + Profile, + ImageURLParams, + CropCode, +} from '@amp/web-app-components/src/components/Artwork/types'; +import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; + +const ARTWORK_IDENTIFIERS = [ + 'xlarge', + 'large', + 'medium', + 'small', + 'xsmall', +] as const; + +function getArtworkProfile(profile: Profile | string): Profile { + const { PROFILES } = ArtworkConfig.get(); + const selectedProfile: Profile = + typeof profile === 'string' ? PROFILES.get(profile) : profile; + // TODO: add validation + warning / error handling for profiles + // rdar://76365525 (Artwork Component: add validation + warning / error handling for profiles) + return selectedProfile; +} + +function buildImgDimensions( + width: number, + aspectRatio: number, + crop: CropCode, +): Partial<ImageURLParams> { + const dimensions = { + width, + height: Math.round(width * (1 / aspectRatio)), + crop, + }; + + return dimensions; +} + +export type ConvertedProfile = { + [key in (typeof ARTWORK_IDENTIFIERS)[number]]?: ImageURLParams; +}; + +export const getAspectRatio = (profile: Profile | string): number => { + const [, aspectRatio] = getArtworkProfile(profile); + return aspectRatio === null ? null : aspectRatio; +}; + +type ImageTagWidthHeight = { width: number; height: number }; +export const getImageTagWidthHeight = ( + profile: Profile | string, +): ImageTagWidthHeight => { + const [imageSize, aspectRatio] = getArtworkProfile(profile); + const width = imageSize[0]; + return { + width, + height: Math.floor(width / aspectRatio), + }; +}; + +export const getDataFromProfile = ( + profile: Profile | string, +): ConvertedProfile => { + const selectedProfile = getArtworkProfile(profile); + + const [widths, aspectRatio, crop] = selectedProfile; + + const imgDimensions = widths.reduce((acc, w, indx) => { + acc[ARTWORK_IDENTIFIERS[indx]] = buildImgDimensions( + w, + aspectRatio, + crop, + ); + return acc; + }, {}); + + return imgDimensions; +}; diff --git a/shared/components/src/components/Artwork/utils/preconnect.ts b/shared/components/src/components/Artwork/utils/preconnect.ts new file mode 100644 index 0000000..652a9a8 --- /dev/null +++ b/shared/components/src/components/Artwork/utils/preconnect.ts @@ -0,0 +1,64 @@ +import { getContext } from 'svelte'; + +const CONTEXT_NAME = 'shared-components:preconnect-tracker'; + +/** + * Setup a PreconnectTracker used by <Artwork> and <MotionVideo>. + * This keeps track of the origins of rendered assets to generate the + * appropriate <link rel="preconnect"> tags. + * + * Preconnect tags should be rendered by placing a <Preconnects /> at the + * bottom of the top level <App> component. + */ +export class PreconnectTracker { + private readonly originsSet: Set<string>; + + /** + * Add a new PreconnectTracker to the Svelte context. + * This should only be called on the server. The components will no-op when + * run clientside (if this isn't called). + */ + static setup(context: Map<string, unknown>): PreconnectTracker { + const tracker = new PreconnectTracker(); + context.set(CONTEXT_NAME, tracker); + return tracker; + } + + private constructor() { + this.originsSet = new Set(); + } + + /** + * Track a URL of an asset for preconnect origin aggregation. + * This should only be called from `<Artwork>` and `<MotionVideo>`. + */ + trackUrl(url: string): void { + try { + const { origin } = new URL(url); + this.originsSet.add(origin); + } catch (_) { + // Just in case the URL parsing fails + // Worst case this misses a preconnect. We'd rather it not take + // down the whole component. + } + } + + /** + * The current list of origins of all rendered <Artwork> and <MotionVideo> + * components. + */ + get origins(): string[] { + return [...this.originsSet]; + } +} + +/** + * Gets the current PreconnectTracker instance from the Svelte context. + * + * @return locale The current instance of Locale + */ +export function getPreconnectTracker(): PreconnectTracker | undefined { + // We intentionally allow this to be missing. In the browse, we want this + // since preconnects are only needed for SSR. + return getContext(CONTEXT_NAME) as PreconnectTracker | undefined; +} diff --git a/shared/components/src/components/Artwork/utils/replaceQualityParam.ts b/shared/components/src/components/Artwork/utils/replaceQualityParam.ts new file mode 100644 index 0000000..81c971a --- /dev/null +++ b/shared/components/src/components/Artwork/utils/replaceQualityParam.ts @@ -0,0 +1,66 @@ +import { QUALITY_PARAM_REGEX } from '@amp/web-app-components/src/components/Artwork/constants'; + +/** + * Utility function that handles the replacement of quality value. + * Does not add any values to the URL string. Just replaces any hardcoded values + * with the quality placeholder. + * + * @param url image url + * @param quality quality value + * @returned url and the defaultQuality from URL + */ +// eslint-disable-next-line import/prefer-default-export +export function replaceQualityParam( + url: string, + quality?: number, +): [string, string] { + const hasQualityPlaceholder = /-\{q\}/.test(url); + // Convert url string to URL object + // Some image URLs, like those for radio stations that are formatted with effect codes, + // may have query params in the path which are used to build out the image with other + // images/effects. Ensure we only modify the image path and not the query params. + const urlObj = new URL(url); + + // Split URL.pathname into parts, so we are only modifying the very last portion of the path + const lastURLPartIdx = urlObj.pathname.lastIndexOf('/'); + const firstURLpart = urlObj.pathname.substring(0, lastURLPartIdx); + let lastURLpart = decodeURI(urlObj.pathname.substring(lastURLPartIdx)); + + let defaultQuality = ''; + + if (quality && !hasQualityPlaceholder) { + // Find an optional hardcoded quality value (e.g. `-80`) + // And then find the `.` and fileType placeholder (ext) + lastURLpart = lastURLpart.replace( + QUALITY_PARAM_REGEX, + (_match, defaultQualityVal: string, fileType: string) => { + // only pass update defaultQuality if it exists in the URL + defaultQuality = defaultQualityVal + ? defaultQualityVal.replace('-', '') + : defaultQuality; + + return `-{q}.${fileType}`; + }, + ); + } else if (!quality && hasQualityPlaceholder) { + // Strip quality param + lastURLpart = lastURLpart.replace('-{q}', ''); + } + + // Update urlObj with our modified pathname parts and then combine all + // parts into a final string. + urlObj.pathname = `${firstURLpart}${lastURLpart}`; + let updatedURL = urlObj.toString(); + + // Need to decode the URL string conversion to preserve curley braces in URL string. + // Only decoding the last part of the URL, in the event that there may be intentionally + // escaped characters in other parts of the URL. + // + // With decode: .../mza_4812113047298400850.png/{w}x{h}AM.RSMA01.jpg + // Without decode: .../mza_4812113047298400850.png/%7Bw%7Dx%7Bh%7DAM.RSMA01.jpg + updatedURL = `${updatedURL.substring(0, lastURLPartIdx)}${decodeURI( + updatedURL.substring(lastURLPartIdx), + )}`; + + return [updatedURL, defaultQuality]; +} diff --git a/shared/components/src/components/Artwork/utils/srcset.ts b/shared/components/src/components/Artwork/utils/srcset.ts new file mode 100644 index 0000000..8f419cb --- /dev/null +++ b/shared/components/src/components/Artwork/utils/srcset.ts @@ -0,0 +1,467 @@ +/** + * COPIED FROM: https://github.pie.apple.com/amp-ui/ember-ui-media-artwork/blob/957fc3e586d4ff710b2263a45d8950d4ee65616a/addon/utils/srcset.js + * and converted to TypeScript + */ +import { replaceQualityParam } from '@amp/web-app-components/src/components/Artwork/utils/replaceQualityParam'; +import { + DEFAULT_FILE_TYPE, + DEFAULT_QUALITY, + PIXEL_DENSITIES, + EMBEDDED_CROP_CODE_REGEX, + EFFECT_ID_REGEX, + FILE_TYPE_REGEX, +} from '@amp/web-app-components/src/components/Artwork/constants'; +import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; +import { memoize } from '@amp/web-app-components/src/utils/memoize'; +import { getDataFromProfile } from '@amp/web-app-components/src/components/Artwork/utils/artProfile'; +import type { MediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions'; +import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions'; +import type { + FileExtension, + Artwork, + ArtworkMaxSizes, + ImageSettings, + ImageURLParams, + Profile, + CropCode, + ChinConfig, +} from '@amp/web-app-components/src/components/Artwork/types'; +import type { Size } from '@amp/web-app-components/src/types'; + +type ProfileConfig = { + width: number; + height: number; + crop: CropCode; +}; +type SizeMap = { + [key in Size]?: ProfileConfig; +}; + +const isAFillCropCode = (crop: CropCode) => crop === 'bf'; + +const getSmallestProfileSize = (sizeMap: SizeMap) => { + const { xlarge, large, medium, small, xsmall } = sizeMap; + return xsmall || small || medium || large || xlarge; +}; + +const filterSizeConfig = ( + config: ProfileConfig, + maxWidth: number | null, +): boolean => (maxWidth ? config.width <= maxWidth : true); + +const getSizesAndBreakpoints = ( + profile: Profile | string, +): [SizeMap, MediaConditions] => { + const { BREAKPOINTS } = ArtworkConfig.get(); + const profileSize = profile ? getDataFromProfile(profile) : {}; + + const mediaConditions = getMediaConditions(BREAKPOINTS); + const SIZES = Object.keys(mediaConditions); + // TODO: rdar://76402413 (Convert imperative reduce pattern + // to functionalwith Object.fromEntries once on Node 12) + const sizeMap: SizeMap = SIZES.reduce((accumulator, sizeName) => { + // only add to size map if + // profile exists for mediaCondition + + if (profileSize[sizeName]) { + const imageWidth = profileSize[sizeName].width; + const imageHeight = profileSize[sizeName].height; + const imageCrop = profileSize[sizeName].crop; + + accumulator[sizeName] = { + width: imageWidth, + height: imageHeight, + crop: imageCrop, + }; + } + + return accumulator; + }, {}); + + return [sizeMap, mediaConditions]; +}; + +function deriveUrlParamsArray( + urlParams: Partial<ImageURLParams>, + profile: Profile | string, + maxWidth: number, +): ImageURLParams[] { + const [profileBySize] = getSizesAndBreakpoints(profile); + + let filteredSizes = Object.values(profileBySize).filter((config) => + filterSizeConfig(config, maxWidth), + ); + + // if image is smaller than all profile sizes + // use the smallest profile size available + if (filteredSizes.length === 0) { + const smallestProfile = getSmallestProfileSize(profileBySize); + filteredSizes = [smallestProfile]; + } + + return filteredSizes.map((viewportProfile) => ({ + crop: viewportProfile.crop, + width: viewportProfile.width, + height: viewportProfile.height, + quality: urlParams.quality, + fileType: urlParams.fileType, + })); +} + +/** + * Converts Artwork object to expected input for image src functions. + * @param artwork Artwork object + * @param quality image quality value + * @param fileType file type + * @param chinConfig chin configuration object + */ +function deriveDataFromArtwork( + artwork: Artwork, + quality?: number, + fileType?: FileExtension, + chinConfig?: ChinConfig, +): [string, Partial<ImageURLParams>, ArtworkMaxSizes] { + const { width, height, template } = artwork; + const chinHeight = chinConfig?.height ?? 0; + + const urlParams: Partial<ImageURLParams> = { + fileType, + quality, + }; + + const ogImageSizes: ArtworkMaxSizes = { + maxHeight: height + chinHeight, + maxWidth: width, + }; + + return [template, urlParams, ogImageSizes]; +} + +/** + * Removes embedded crop codes if: + * 1. a `crop` is passed (i.e. if a user passed a crop code in the invocation of + * the outer function) + * 2. the rawURL has an embedded crop code that is not an Effect ID + * + * Exception to #2 is when using an image with an Effect ID that is being used to create + * a chin blur (i.e. chins in Power Swoosh lockups). This is a special case so we can + * have the blur effect visible in Chrome. + * + * Under these conditions the fileType is also removed, but it's not clear why. + * + * @public + * @param rawURL + * @param crop + * @param replaceEffectCode + */ +export function fixEmbeddedCropCode( + rawURL: string, + crop: string, + replaceEffectCode = false, +): string { + // Normalize URL in case crop or format are hardcoded + // Test against only the filename portion + const stringParts = rawURL.split('/'); + const fileName = stringParts.pop(); + let url = rawURL; + + const cropMatches = fileName.match(EMBEDDED_CROP_CODE_REGEX); + + // The last match will be the hard-coded crop code or the replacement indicator: {c} + const cropMatch = cropMatches ? cropMatches.pop() : null; + + // EffectIds (e.g. SH.FPESS01) are the new artwork crop codes + // that should not be replaced in the artwork url excpet when used + // for chin blurs. + const isEffectMatch = !replaceEffectCode && EFFECT_ID_REGEX.test(fileName); + + if (crop && cropMatch && !isEffectMatch) { + // Update the url to include the replacement indicator {c} instead of the hard-coded crop value + // Also update the URL to include the replacement indicator {f} if the file type is hard-coded + const updatedFilename = replaceEffectCode + ? // EFFECT_ID_REGEX also captures file type + fileName.replace(EFFECT_ID_REGEX, '$1x$2{c}.{f}') + : fileName + .replace(EMBEDDED_CROP_CODE_REGEX, '$1x$2{c}') + .replace(FILE_TYPE_REGEX, '{f}'); + + url = `${stringParts.join('/')}/${updatedFilename}`; + } + + return url; +} + +/** + * @private + * Utility for build src for images + * @param url template url for an image + * @param urlParams + * @param options + * @param chinConfig optional chin configuration for style parameter + */ +export function buildSrc( + url: string, + urlParams: ImageURLParams, + options: ImageSettings, + chinConfig?: ChinConfig, +): string | null { + if (!url) return null; + + let returnedUrl = url; + + const { width, height, quality, crop, fileType } = urlParams; + + if (options?.forceCropCode !== false) { + returnedUrl = fixEmbeddedCropCode(returnedUrl, crop); + } + const [parsedURL, defaultQuality] = replaceQualityParam( + returnedUrl, + quality, + ); + returnedUrl = parsedURL; + + const qualityValue = Number.isInteger(quality) + ? quality.toString() + : defaultQuality; + + let finalUrl = returnedUrl + .replace('{w}', width?.toString()) + .replace('{h}', height?.toString()) + .replace('{c}', crop) + .replace('{q}', qualityValue) + .replace('{f}', fileType); + + // Add style query parameter for chin effects if specified + if (chinConfig?.style) { + const separator = finalUrl.includes('?') ? '&' : '?'; + finalUrl += `${separator}style=${chinConfig.style}`; + } + + return finalUrl; +} + +/** + * Wrapper for buildSrc helper + * - Preserves effect ids in urls used for SEO + * @param {string} url + * @param {ImageURLParams} urlParams + * @return string | null + */ +export function buildSrcSeo( + url: string, + urlParams: ImageURLParams, +): string | null { + const options = { ...urlParams }; + + // Preserve effect ids when generating seo image urls + if (EFFECT_ID_REGEX.test(url)) { + delete options.crop; + } + + return buildSrc(url, options, {}); +} + +/** + * This function generates a value for the `srcset` attribute + * based on a URL and image options. + * + * @private + * @param rawURL The raw URL + * @param urlParams custom image parameters + * @param pixelDensity pixel density to optimize for + * @param options k/v map of other constant options that don't depend on viewport size. + * @return The `srcset` attribute value + * @public + */ +function buildSingleSrcset( + rawURL: string, + urlParams: ImageURLParams, + artworkSizes: ArtworkMaxSizes, + pixelDensity: number, + options: ImageSettings, + chinConfig?: ChinConfig, +): string { + const { maxWidth } = artworkSizes; + const profileHeight = urlParams.height; + const profileWidth = urlParams.width; + const chinHeight = chinConfig?.height ?? 0; + + const calculatedWidth = Math.ceil(profileWidth * pixelDensity); + const { crop } = urlParams; + + // use profile width if maxWidth is null or 0 + // TODO: rdar://92133085 (Add logging to shared components) + const artworkMaxWidth = maxWidth || calculatedWidth; + + // prevent pixel dense images from being wider + // than the OG size of the image + // unless its using a fill + const width = isAFillCropCode(crop) + ? calculatedWidth + : Math.min(calculatedWidth, artworkMaxWidth); + const height = + Math.round((width * profileHeight) / profileWidth) + + Math.round(chinHeight * pixelDensity); + + const passedOptions = options; + + const fixedUrlParams = { + ...urlParams, + crop, + width, + height, + }; + + const url = buildSrc(rawURL, fixedUrlParams, passedOptions, chinConfig); + + return `${url} ${fixedUrlParams.width}w`; +} + +/** + * Returns a string that can be used as the value for the srcset attribute. + * + * @function buildResponsiveSrcset + * @param urlParams list of `urlOptions`. See `buildSrcset` for details. + * @param options some other options to opt into behavior. See `buildSrcset` for details. + * @returns srcset string + */ +export function buildResponsiveSrcset( + url: string, + urlParams: Partial<ImageURLParams>, + profile: Profile | string, + artworkSizes: ArtworkMaxSizes, + options: ImageSettings, + chinConfig?: ChinConfig, +): string { + const urlParamsArray = deriveUrlParamsArray( + urlParams, + profile, + artworkSizes.maxWidth, + ); + const DEFAULT_OPTIONS: Partial<ImageSettings> = { + forceCropCode: false, + }; + const { + pixelDensities = PIXEL_DENSITIES, + ...optionsWithoutPixelDensities + } = options; + + // merging custom options with defaults + const finalOptions: ImageSettings = { + ...DEFAULT_OPTIONS, + ...optionsWithoutPixelDensities, + }; + + // using a Set to prevent multiple of the same srcs being added. + const srcSetStrings = new Set(); + + // eslint-disable-next-line no-restricted-syntax + for (const pixelDensity of pixelDensities) { + // eslint-disable-next-line no-restricted-syntax + for (const singleURLParam of urlParamsArray) { + srcSetStrings.add( + buildSingleSrcset( + url, + singleURLParam, + artworkSizes, + pixelDensity, + finalOptions, + chinConfig, + ), + ); + } + } + return [...srcSetStrings].join(','); +} + +/** + * get size attributes based on breakpoints. + * @param width width of image + * @param height height of image + * @param imageMultipler custom multipler to use for image sizes + */ + +function imageSizes( + profile?: Profile | string, + maxWidth: number = null, +): string { + const [sizeMap, mediaConditions] = getSizesAndBreakpoints(profile); + + const filteredSizes = Object.entries(sizeMap).filter(([, config]) => + filterSizeConfig(config, maxWidth), + ); + + const sizes = filteredSizes.map(([sizeName, config], index, arr) => { + let condition = mediaConditions[sizeName]; + const { width } = config; + const widthString = `${width}px`; + const isFirst = index === 0; + const isLast = index === arr.length - 1; + + // The smallest size in the 'sizes' attribute shouldn't have a min size + // or it will cause anything below that size to default + // to the last size (aka the largest image). + if (isFirst) { + const conditions = condition.split('and'); + if (conditions.length > 1) { + const [, maxCondition] = conditions; + condition = maxCondition; + } + } + if (isLast) { + // The last size in the `sizes` attr should not contain the media condition + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes + return widthString; + } + + // Creates an option like this: + // (min-width: something) 111px; + return `${condition} ${widthString}`; + }); + return sizes.length + ? sizes.join(',') + : `${getSmallestProfileSize(sizeMap).width}w`; +} + +export const getImageSizes = memoize(imageSizes); + +export function buildSourceSet( + artwork: Artwork, + options: ImageSettings, + profile: Profile | string, + chinConfig?: ChinConfig, +): string | null { + const fileType = options.fileType || DEFAULT_FILE_TYPE; + let qualityValue = options.quality || DEFAULT_QUALITY; + let sourceSet = null; + + const isWebp = fileType === 'webp'; + if (isWebp && qualityValue === DEFAULT_QUALITY) { + qualityValue = null; + } + + const [url, urlParams, maxSizes] = deriveDataFromArtwork( + artwork, + qualityValue, + fileType, + chinConfig, + ); + + if (url) { + // If the url doesn't have a {f} (file type) placeholder, we do not want + // to force webp sources. + const isNotWebpException = !(isWebp && !url.includes('{f}')); + if (isNotWebpException) { + sourceSet = buildResponsiveSrcset( + url, + urlParams, + profile, + maxSizes, + options, + chinConfig, + ); + } + } + + return sourceSet; +} diff --git a/shared/components/src/components/Artwork/utils/validateBackground.ts b/shared/components/src/components/Artwork/utils/validateBackground.ts new file mode 100644 index 0000000..42f6b7a --- /dev/null +++ b/shared/components/src/components/Artwork/utils/validateBackground.ts @@ -0,0 +1,16 @@ +const IS_RGB = /^rgba?\(\s*[\d.]+\s*%?\s*(,\s*[\d.]+\s*%?\s*){2,3}\)$/; +const IS_HEX = /^([0-9a-f]{3}){1,2}$/i; + +// eslint-disable-next-line import/prefer-default-export +export const deriveBackgroundColor = (str: string | null): string => { + const background = str?.replace('#', ''); + + if (IS_HEX.test(background)) { + return `#${background}`; + } + + if (IS_RGB.test(background)) { + return background; + } + return ''; +}; diff --git a/shared/components/src/components/Error/ErrorPage.svelte b/shared/components/src/components/Error/ErrorPage.svelte new file mode 100644 index 0000000..d459b4e --- /dev/null +++ b/shared/components/src/components/Error/ErrorPage.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import Button from '@amp/web-app-components/src/components/buttons/Button.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); + + interface ErrorUserInfo { + status: number; + } + + interface AppError { + message?: string; + isFirstPage?: boolean; + userInfo?: ErrorUserInfo; + statusCode?: number; + } + + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + + export let isRetryError: (error: AppError) => boolean = () => false; + + export let error: AppError | null = null; + export let errorLocKey: string | null = null; + + // podcasts-client-js can currently return a 204 if there is no content found. + // We want to treat this as a 204. If the following radar is ever addressed, + // we can remove the 204 conditional here: + // rdar://106657358 (Investigate if we can switch from 204 to 404s for network errors) + $: locKey = + errorLocKey || + (error?.userInfo?.status === 404 || + error?.message === '404' || + error?.statusCode === 404 || + error?.statusCode === 204 + ? 'AMP.Shared.Error.ItemNotFound' + : 'FUSE.Error.AnErrorOccurred'); + + function retry(): void { + dispatch('retryAction'); + } +</script> + +<!-- TODO: rdar://92841405 (JMOTW: Show error page when user has lost internet connection) --> +<div role="status" class="page-error"> + <h1 class="page-error__title" data-testid="page-error-title"> + {translateFn(locKey)} + </h1> + + {#if isRetryError(error)} + <Button buttonStyle="buttonB" on:buttonClick={retry}> + {translateFn('FUSE.Error.TryAgain')} + </Button> + {/if} +</div> + +<style lang="scss"> + .page-error { + --buttonTextColor: var(--systemSecondary); + --buttonBorderColor: var(--systemSecondary); + margin: auto; + padding: 0 25px; + max-width: 440px; + color: var(--systemSecondary); + position: absolute; + top: 50%; + left: 50%; // RTL not needed + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + text-align: center; + z-index: var(--z-default); + } + + .page-error__title { + margin-bottom: 5px; + font: var(--title-2); + } +</style> diff --git a/shared/components/src/components/Footer/Footer.svelte b/shared/components/src/components/Footer/Footer.svelte new file mode 100644 index 0000000..82b0ff2 --- /dev/null +++ b/shared/components/src/components/Footer/Footer.svelte @@ -0,0 +1,195 @@ +<script lang="ts" context="module"> + export type Translate = ( + str: string, + options?: Record<string, string | number>, + ) => string; +</script> + +<script lang="ts"> + import type { FooterItem } from '@amp/web-app-components/src/components/Footer/types'; + /** + * Available CSS Vars: + * --footerBg + * + * StyleKit Vars: + * --keyColor + * --systemPrimary + * --systemSecondary + * --systemQuaternary + */ + + /** + * translate function provided by the parent app. + */ + export let translateFn: Translate; + /** + * A list of links to be in the footer + * @type {Array<FooterItem>} + */ + export let footerItems: FooterItem[]; + + const year = new Date().getFullYear().toString(); +</script> + +<footer data-testid="footer"> + <div class="footer-secondary-slot"> + <slot name="secondary-content" /> + </div> + + <div class="footer-contents"> + <p> + <span dir="ltr"> + <span dir="auto" + >{translateFn('AMP.Shared.Footer.CopyrightYear', { + year, + })}</span + > + <a + href={translateFn('AMP.Shared.Footer.Apple.URL')} + rel="noopener" + ><span dir="auto" + >{translateFn('AMP.Shared.Footer.Apple.Text')}</span + ></a + > + </span> + <span dir="auto" + >{translateFn('AMP.Shared.Footer.AllRightsReserved')}</span + > + </p> + <ul> + {#each footerItems as { url, locKey, id } (id)} + <li data-testid={id}> + <a href={translateFn(url)} rel="noopener" dir="auto"> + {translateFn(locKey)} + </a> + </li> + {/each} + </ul> + </div> +</footer> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/typography/specs' as *; + @use 'ac-sasskit/core/selectors' as *; + @use 'ac-sasskit/core/viewports' as *; + @use 'amp/stylekit/core/fonts' as *; + @use 'amp/stylekit/core/specs' as *; + @use 'amp/stylekit/modules/fontsubsets/core' as *; + @use '@amp/web-shared-styles/app/core/viewports' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + $footer-height-sidebar-visible: 88px; + $footer-height-xsmall: 147px; + $footer-height-small: 88px; + $footer-vertical-padding-xsmall: var(--footerVerticalPadding, 15px); + $footer-vertical-padding-small: var(--footerVerticalPadding, 14px); + + footer { + flex-shrink: 0; + min-height: $footer-height-xsmall; + padding: $footer-vertical-padding-xsmall var(--bodyGutter); + background-color: var(--footerBg); + display: block; + + @include typespec(Footnote); + + // Footer.svelte should use viewport mixins for media queries + // this allows for cross compatibility with apps that may have + // differing xsmall vs small viewports set up + @include viewport('range:sidebar:hidden down') { + padding-bottom: $global-player-bar-height + + $footer-vertical-padding-xsmall; + } + + @include viewport(small) { + min-height: $footer-height-sidebar-visible; + padding-top: $footer-vertical-padding-small; + padding-bottom: $footer-vertical-padding-small; + + @include typespec(Subhead); + } + + @include viewport(xlarge) { + align-content: flex-start; + align-items: baseline; + display: var(--footerDisplay, flex); + justify-content: space-between; + } + + @include feature-detect(is-footer-hidden) { + display: none; + } + + // Hide Footer for Replay Highlights + :global(.maximize-content-area) & { + display: none; + } + } + + .footer-contents { + @include viewport(small) { + order: 1; + } + + p { + margin-bottom: 5px; + color: var(--systemSecondary); + } + + a { + --linkColor: var(--systemPrimary); + } + + ul { + display: flex; + flex-wrap: wrap; + } + + li { + display: inline-flex; + line-height: 1; + margin-top: 6px; + vertical-align: middle; + + a { + height: 100%; + padding-inline-end: 10px; + } + + &::after { + border-inline-start: 1px solid var(--systemQuaternary); + content: ''; + padding-inline-end: 10px; + } + + &:last-child::after { + content: none; + } + } + } + + .footer-secondary-slot { + --linkColor: var(--systemSecondary); + order: 1; + // Font subsets for Geos prevents `SF Pro` Web Font from being + // downloaded after `BlinkMacSystemFont` fails in Chrome. + font-family: font-family-locale(en-WW, geos); + + @each $lang, $font in font-family(geos) { + @if $lang != en-WW { + :global([lang]:lang(#{$lang})) & { + font-family: $font; + } + } + } + + @include viewport(small) { + order: 2; + } + + @include viewport('range:xsmall down') { + min-width: auto; + } + } +</style> diff --git a/shared/components/src/components/LineClamp/LineClamp.svelte b/shared/components/src/components/LineClamp/LineClamp.svelte new file mode 100644 index 0000000..9e4be3d --- /dev/null +++ b/shared/components/src/components/LineClamp/LineClamp.svelte @@ -0,0 +1,238 @@ +<script lang="ts" context="module"> + // A single observer is shared for all LineClamp instances for better performance. + // Using an observer also means recalculations are batched so layout only has to be + // recalculated once regardless of the number of instances of this component. + const resizeObserver = + typeof window !== 'undefined' && window.ResizeObserver + ? new window.ResizeObserver((entries) => { + for (const entry of entries) { + const contentHeight = Math.ceil(entry.contentRect.height); + const scrollHeight = Math.ceil(entry.target.scrollHeight); + const borderBoxHeight = Math.ceil( + entry.borderBoxSize[0].blockSize, + ); + + const style = getComputedStyle(entry.target); + + const lineHeight = parseInt( + style.getPropertyValue('line-height'), + ); + const multiline = contentHeight > lineHeight; + const multilineCount = contentHeight / lineHeight; + const truncated = scrollHeight > borderBoxHeight; + + const event = new CustomEvent<LineClampResizeDetail>( + 'lineClampResize', + { + detail: { + multiline, + multilineCount, + truncated, + }, + }, + ); + entry.target.dispatchEvent(event); + } + }) + : null; +</script> + +<script lang="ts"> + import { onMount, createEventDispatcher } from 'svelte'; + import { getRafQueue } from '@amp/web-app-components/src/utils/rafQueue'; + + /* + * Number of lines to clamp the container contents. + */ + export let clamp: number = 1; + + /** + * Whether the clamp container should be observed for multiline change events. + * + * Observed containers emit the `resize` event with event detail + * { multiline: boolean, truncated: boolean }. + * - multiline (boolean): whether the container is more than one line tall + * - truncated (boolean): whether the text is truncated + * + * This can be used for conditional styling of other clamp containers which + * may be allowed to expand if an adjacent container is only a single line. + */ + export let observe: boolean = false; + + /* + * Whether to allow focus indicators to overflow the container. + * + * Line clamping requires `overflow: hidden` in order to hide truncated contents. + * However, this will also clip focus indicators of elements inside the clamped + * container. Setting this to `true` allows focus indicators to overflow the + * clamped container while still hiding truncated contents. + * + * The amount of overflow bleed defaults to the Sass variable `$focus-size`, but + * can be adjusted using the CSS property `--overflowBleedSize`. + */ + export let allowFocusOverflow: boolean = false; + + /** + * Since slots are not able to be wrapped ( https://github.com/sveltejs/svelte/issues/5604) + * We use this prop to determine if the badge should be rendered. + */ + export let shouldRenderBadgeSlots: boolean = true; + + let clampElement: HTMLElement; + + let multiline: boolean = false; + let truncated: boolean = false; + + if (observe && resizeObserver) { + const dispatch = createEventDispatcher(); + const rafQueue = getRafQueue(); + + onMount(() => { + resizeObserver.observe(clampElement); + clampElement.addEventListener( + 'lineClampResize', + (e: CustomEvent<LineClampResizeDetail>) => { + dispatch('resize', e.detail); + + // Multiline/truncation state is used for badge positioning + if ($$slots.badge && shouldRenderBadgeSlots) { + rafQueue.add(() => { + multiline = e.detail.multiline; + truncated = e.detail.truncated; + }); + } + }, + ); + + return () => { + resizeObserver.unobserve(clampElement); + }; + }); + } +</script> + +<!-- svelte-ignore a11y-unknown-role --> +<div + class="multiline-clamp" + class:multiline-clamp--overflow={allowFocusOverflow} + class:multiline-clamp--multiline={multiline} + class:multiline-clamp--truncated={truncated} + class:multiline-clamp--with-badge={$$slots.badge && shouldRenderBadgeSlots} + style="--mc-lineClamp: var(--defaultClampOverride, {clamp});" + bind:this={clampElement} + role="text" +> + <!-- + NOTE: Any elements slotted here *must* have `display: inline`, + otherwise the clamping will not take effect! + + NOTE: In order for a multiline clamp with a badge to wrap correctly, + there must be *no whitespace* between the text element and badge + element. Otherwise, the badge will not "stick" to the last word, and + can end up wrapping onto its own line. + --> + <span class="multiline-clamp__text"><slot /></span + >{#if $$slots.badge && shouldRenderBadgeSlots}<span + class="multiline-clamp__badge"><slot name="badge" /></span + >{/if} +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'amp/stylekit/core/mixins/overflow-bleed' as *; + @use 'amp/stylekit/core/mixins/line-clamp' as *; + + // Line Clamp + // + // PUBLIC CSS PROPS + // + // *cssprop {Number} --overflowBleedSize + // *access public + // Size of overflow bleed used when component prop `allowFocusOverflow` + // is `true`. + // + // *cssprop {Number} --badgeSize + // *access public + // Size of badge placed in component's `badge` slot, used for positioning + // when the line clamp overflows to multiple lines. + // + // + // PRIVATE CSS PROPS + // + // *cssprop {Number} --mc-overflowBleedSize [var(--overflowBleedSize, 0)] + // *access private + // Size of overflow bleed. + // + // *cssprop {Number} --mc-badgeSize [var(--badgeSize, 8px)] + // *access private + // Size of badge placed in component's `badge` slot. + // + // *cssprop {Number} --mc-badgeSpacing [var(--mc-badgeSize) + var(--mc-overflowBleedSize)] + // *access private + // Positioning helper to ensure badge wraps with text and doesn't + // get truncated. + // + // *cssprop {Number} --mc-lineClamp [1] + // *access private + // Number of lines to clamp. + // + + .multiline-clamp { + --mc-overflowBleedSize: var(--overflowBleedSize, 0); + --mc-badgeSize: var(--badgeSize, 8px); + --mc-badgeSpacing: var(--mc-badgeSize); + word-break: break-word; // Allow long words to be truncated + + @include line-clamp(var(--mc-lineClamp, 1)); + } + + .multiline-clamp--overflow { + --mc-overflowBleedSize: var(--overflowBleedSize, #{$focus-size}); + --mc-badgeSpacing: calc( + var(--mc-badgeSize) + var(--mc-overflowBleedSize) + ); + + // Clip overflow contents when unfocused in order to prevent content + // that falls within the overflow padding box from being displayed. + clip-path: inset(var(--mc-overflowBleedSize)); + + // If container scrolls due to focus, keep focused item visible + scroll-padding: var(--mc-overflowBleedSize); + + @include overflow-bleed(var(--mc-overflowBleedSize)); + + &:focus-within { + clip-path: none; + } + } + + .multiline-clamp--with-badge { + &.multiline-clamp--truncated { + position: relative; + + // Adjust padding at end of clamp container so badge doesn't overlap text + padding-inline-end: var(--mc-badgeSpacing); + z-index: var(--z-default); + + .multiline-clamp__badge { + display: block; + position: absolute; + bottom: var(--mc-overflowBleedSize); + inset-inline-end: var(--mc-overflowBleedSize); + z-index: var(--z-default); + } + } + + // These styles on the text and badge create the effect of "sticking" + // the badge to the last word, so the badge never wraps to a new line on + // its own. + .multiline-clamp__text { + padding-inline-end: var(--mc-badgeSpacing); + } + + .multiline-clamp__badge:not(:empty) { + margin-inline-start: calc(-1 * var(--mc-badgeSpacing)); + } + } +</style> diff --git a/shared/components/src/components/LoadingSpinner/LoadingSpinner.svelte b/shared/components/src/components/LoadingSpinner/LoadingSpinner.svelte new file mode 100644 index 0000000..896c8b8 --- /dev/null +++ b/shared/components/src/components/LoadingSpinner/LoadingSpinner.svelte @@ -0,0 +1,260 @@ +<script lang="ts"> + // Delay until the spinner fades in + export let delay: number = 0; + export let inset: boolean = false; + export let small: boolean = false; + export let ariaLoading: string = ''; +</script> + +<div + class="loading-spinner" + class:inset + class:loading-spinner--small={small} + data-testid="loading-spinner" + style="animation-delay: {delay}ms" + aria-label={ariaLoading} +> + <div class="pulse-spinner"> + <div class="pulse-spinner__container"> + <div class="pulse-spinner__nib pulse-spinner__nib--1" /> + <div class="pulse-spinner__nib pulse-spinner__nib--2" /> + <div class="pulse-spinner__nib pulse-spinner__nib--3" /> + <div class="pulse-spinner__nib pulse-spinner__nib--4" /> + <div class="pulse-spinner__nib pulse-spinner__nib--5" /> + <div class="pulse-spinner__nib pulse-spinner__nib--6" /> + <div class="pulse-spinner__nib pulse-spinner__nib--7" /> + <div class="pulse-spinner__nib pulse-spinner__nib--8" /> + </div> + </div> +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'ac-sasskit/core/selectors' as *; + @use 'amp/stylekit/core/mixins/materials' as *; + @use 'sass:math'; + + // Loading spinner contains `@amp/pulse-spinner` + + .loading-spinner { + margin: auto; + opacity: 0; + animation: fade-in 100ms; + animation-fill-mode: forwards; + text-align: center; + z-index: var(--z-default); + + &:not(.inset) { + position: absolute; + top: 50%; + left: 50%; // RTL not needed + + @media (--small) { + &:not(.loading-spinner--small) { + transform: translate(-50%, -50%); + } + } + } + + &.inset { + transform: translateX(50%); + + @include rtl { + transform: translateX(-50%); + } + } + } + + @keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } + } + + //// + /// Pulse Spinner (Big Sur) + /// Styles from `@amp/pulse-spinner` + /// https://github.pie.apple.com/amp-web/pulse-spinner + //// + + /// + /// Spinner small container size + /// + /// @type Number + /// + $spinner-container-small: 16px; + + /// + /// Spinner large container size + /// + /// @type Number + /// + $spinner-container-large: 32px; + + /// + /// Spinner nib distance + /// + /// @type Value + /// + $spinner-nib-distance: 40px; + + /// + /// Spinner nib count + /// + /// @type Number + /// + $spinner-nibs: 8; + + /// + /// Spinner duration + /// + /// @type Number + /// + $spinner-duration: 0.8s; + + /// + /// Spinner small scaling value + /// + /// @type Value | Number + /// + $spinner-small-scale: scale(0.075); + + /// + /// Spinner large scaling value + /// + /// @type Value | Number + /// + $spinner-large-scale: 0.15; + + /// + /// Spinner inactive opacity + /// + /// @type Number + /// + $spinner-inactive-opacity: 0.5; + + .pulse-spinner { + position: relative; + width: $spinner-container-small; + height: $spinner-container-small; + + @include feature-detect($inactive-window-classname) { + opacity: $spinner-inactive-opacity; // AppKit inactive style, when window is not in focus + } + + @media (--small) { + .loading-spinner:not(.loading-spinner--small) & { + width: $spinner-container-large; + height: $spinner-container-large; + } + } + } + + .pulse-spinner__container { + position: absolute; + width: 0; + transform: $spinner-small-scale; + z-index: var(--z-default); + + @media (--small) { + .loading-spinner:not(.loading-spinner--small) & { + top: 50%; + left: 50%; + transform: scale(#{$spinner-large-scale}); + + @include rtl { + // Adjust for scale + right: #{$spinner-large-scale * 100%}; + } + } + } + } + + .pulse-spinner__nib { + position: absolute; + top: -12.5px; + width: 66px; + height: 28px; + background: transparent; + border-radius: 25% / 50%; + transform-origin: left center; + + &::before { + width: 100%; + height: 100%; + display: block; + content: ''; + background: rgb(0, 0, 0); + border-radius: 25% / 50%; + animation-duration: $spinner-duration; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-direction: normal; + animation-fill-mode: none; + animation-play-state: running; + animation-name: spinner-line-fade-default; + + @media (prefers-color-scheme: dark) { + background: rgb(255, 255, 255); + } + + @media (prefers-contrast: more) { + animation-name: spinner-line-fade-increased-contrast; + } + } + } + + @for $i from 0 to $spinner-nibs { + .pulse-spinner__nib--#{$i + 1} { + $degrees: math.div(360, $spinner-nibs) * $i; + $nib-delay: $spinner-duration - + (math.div($spinner-duration, $spinner-nibs) * $i); + transform: rotate(#{$degrees}deg) translateX($spinner-nib-distance); + + &::before { + animation-delay: -$nib-delay; + } + } + } + + $spinner-nib-minimum-opacity: 0.08; + $spinner-nib-maxiumum-opacity: 0.55; + $spinner-nib-minimum-opacity-increased-contrast: 0.1; + $spinner-nib-maxiumum-opacity-increased-contrast: 0.8; + + @keyframes spinner-line-fade-default { + 0%, + 100% { + opacity: $spinner-nib-maxiumum-opacity; + } + + 95% { + opacity: $spinner-nib-minimum-opacity; // minimum opacity + } + + 1% { + opacity: $spinner-nib-maxiumum-opacity; // maximum opacity + } + } + + // Increased Contrast Fade + @keyframes spinner-line-fade-increased-contrast { + 0%, + 100% { + opacity: $spinner-nib-maxiumum-opacity-increased-contrast; + } + + 95% { + opacity: $spinner-nib-minimum-opacity-increased-contrast; // minimum opacity + } + + 1% { + opacity: $spinner-nib-maxiumum-opacity-increased-contrast; // maximum opacity + } + } +</style> diff --git a/shared/components/src/components/MetaTags/MetaTags.svelte b/shared/components/src/components/MetaTags/MetaTags.svelte new file mode 100644 index 0000000..d526275 --- /dev/null +++ b/shared/components/src/components/MetaTags/MetaTags.svelte @@ -0,0 +1,262 @@ +<script lang="ts"> + import { LTR_MARK, RTL_MARK } from '@amp/web-app-components/src/constants'; + import type { Locale } from '@amp/web-app-components/src/types'; + import type { + SeoData, + HreflangTag, + } from '@amp/web-app-components/src/components/MetaTags/types'; + import type { ImageURLParams } from '@amp/web-app-components/src/components/Artwork/types'; + import { buildSrcSeo } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import { serializeJSONData } from '@amp/web-app-components/src/utils/sanitize'; + + export let seoData: SeoData | undefined = undefined; + export let locale: Locale; + export let origin: string; + export let pageDir: string; + export let defaultTitle: string; + export let hreflangTags: HreflangTag[] | null = null; + + // Music's Classical Bridge prefers to use a different canonical + // for rel=canonical tags than the page url. Uses page url as fallback. + $: canonicalUrl = seoData?.canonicalUrl ?? seoData?.url; + $: pageTitle = seoData?.pageTitle ?? defaultTitle; + $: formattedLocale = locale.language.replace(/-/g, '_') || null; + $: directionMarker = pageDir === 'rtl' ? RTL_MARK : LTR_MARK; + + function processSocialImage( + artworkUrl: string, + imgParams: ImageURLParams, + ): string | undefined { + if (artworkUrl.startsWith('/')) { + artworkUrl = `${origin}${artworkUrl}`; + } + return buildSrcSeo(artworkUrl, imgParams); + } + + $: ogImageUrl = !!seoData?.artworkUrl + ? processSocialImage(seoData.artworkUrl, { + width: seoData.width, + height: seoData.height, + crop: seoData.crop, + fileType: seoData.fileType, + quality: seoData.quality, + }) + : null; + $: twitterImageUrl = !!seoData?.artworkUrl + ? processSocialImage(seoData.artworkUrl, { + width: seoData.twitterWidth, + height: seoData.twitterHeight, + crop: seoData.twitterCropCode, + fileType: seoData.fileType, + quality: seoData.quality, + }) + : null; + + $: sanitizedSchemaContent = !!seoData?.schemaContent + ? serializeJSONData(seoData.schemaContent) + : null; + + $: sanitizedBreadcrumbSchemaContent = !!seoData?.breadcrumbSchemaContent + ? serializeJSONData(seoData.breadcrumbSchemaContent) + : null; +</script> + +<svelte:head> + {#if pageTitle} + <!--directionMarker forces the direction so we don't get "....More from "some rtl text""--> + <title>{directionMarker}{pageTitle}</title> + {/if} + + {#if !!seoData} + <!-- Begin General --> + <!-- NOTE: If configuring robots tags, use one of these options, but not both --> + {#if seoData.noFollow} + <!-- Use this when you do not want your page indexed or your links followed --> + <meta name="robots" content="noindex, nofollow" /> + {:else if seoData.noIndex} + <!-- Use this when you want your links followed but not have the page indexed --> + <meta name="robots" content="noindex" /> + {/if} + + {#if seoData.description} + <meta name="description" content={seoData.description} /> + {/if} + + {#if seoData.keywords} + <meta name="keywords" content={seoData.keywords} /> + {/if} + + {#if canonicalUrl} + <link rel="canonical" href={canonicalUrl} /> + {/if} + + {#if hreflangTags} + {#each hreflangTags as langTag} + {#if langTag} + <link + rel="alternate" + href={langTag.path} + hreflang={langTag.tag} + /> + {/if} + {/each} + {/if} + <!-- End General --> + + {#if !!seoData.oembedData?.url} + <link + rel="alternate" + type="application/json+oembed" + href={`${origin}/api/oembed?url=${encodeURIComponent( + seoData.oembedData.url, + )}`} + title={seoData.oembedData.title ?? ''} + /> + {/if} + + <!-- Begin Apple-specific meta tags --> + {#if seoData.appleStoreId} + <meta name="al:ios:app_store_id" content={seoData.appleStoreId} /> + {/if} + + {#if seoData.appleStoreName} + <meta name="al:ios:app_name" content={seoData.appleStoreName} /> + {/if} + + {#if seoData.appleContentId} + <meta name="apple:content_id" content={seoData.appleContentId} /> + {/if} + + {#if seoData.appleTitle} + <meta name="apple:title" content={seoData.appleTitle} /> + {/if} + + {#if seoData.appleDescription} + <meta name="apple:description" content={seoData.appleDescription} /> + {/if} + <!-- End Apple-specific meta tags --> + + <!-- Begin OpenGraph (FaceBook, Slack, etc) --> + {#if seoData.socialTitle} + <meta property="og:title" content={seoData.socialTitle} /> + {/if} + + {#if seoData.socialDescription} + <meta + property="og:description" + content={seoData.socialDescription} + /> + {/if} + + {#if seoData.siteName} + <meta property="og:site_name" content={seoData.siteName} /> + {/if} + + {#if seoData.url} + <meta property="og:url" content={seoData.url} /> + {/if} + + {#if ogImageUrl} + <meta property="og:image" content={ogImageUrl} /> + <meta property="og:image:secure_url" content={ogImageUrl} /> + + {#if seoData.imageAltTitle} + <meta property="og:image:alt" content={seoData.imageAltTitle} /> + {:else if seoData.socialTitle} + <meta property="og:image:alt" content={seoData.socialTitle} /> + {/if} + + {#if seoData.width} + <meta + property="og:image:width" + content={seoData.width.toString()} + /> + {/if} + + {#if seoData.height} + <meta + property="og:image:height" + content={seoData.height.toString()} + /> + {/if} + + {#if seoData.fileType} + <meta + property="og:image:type" + content={`image/${seoData.fileType}`} + /> + {/if} + {/if} + + {#if seoData.ogType} + <meta property="og:type" content={seoData.ogType} /> + {/if} + + {#if seoData.socialTitle && formattedLocale} + <meta property="og:locale" content={formattedLocale} /> + {/if} + + {#if $$slots['extendedOpenGraphData']} + <slot name="extendedOpenGraphData" /> + {/if} + <!-- End OpenGraph --> + + <!-- Begin Twitter --> + {#if seoData.socialTitle} + <meta name="twitter:title" content={seoData.socialTitle} /> + {/if} + + {#if seoData.socialDescription} + <meta + name="twitter:description" + content={seoData.socialDescription} + /> + {/if} + + {#if seoData.twitterSite} + <meta name="twitter:site" content={seoData.twitterSite} /> + {/if} + + {#if twitterImageUrl} + <meta name="twitter:image" content={twitterImageUrl} /> + + {#if seoData.imageAltTitle} + <meta + name="twitter:image:alt" + content={seoData.imageAltTitle} + /> + {:else if seoData.socialTitle} + <meta name="twitter:image:alt" content={seoData.socialTitle} /> + {/if} + {/if} + + {#if seoData.twitterCardType} + <meta name="twitter:card" content={seoData.twitterCardType} /> + {/if} + <!-- End Twitter --> + + <!-- Begin schema.org --> + {#if $$slots['schemaOrganizationData']} + <slot name="schemaOrganizationData" /> + {/if} + + {#if seoData.schemaName && sanitizedSchemaContent} + {@html ` + <script id=${seoData.schemaName} type="application/ld+json"> + ${sanitizedSchemaContent} + </script> + `} + {/if} + <!-- End schema.org --> + + <!-- Begin breadcrumb schema --> + {#if seoData.breadcrumbSchemaName && sanitizedBreadcrumbSchemaContent} + {@html ` + <script id=${seoData.breadcrumbSchemaName} name=${seoData.breadcrumbSchemaName} type="application/ld+json"> + ${sanitizedBreadcrumbSchemaContent} + </script> + `} + {/if} + <!-- End breadcrumb schema --> + {/if} +</svelte:head> diff --git a/shared/components/src/components/Modal/ContentModal.svelte b/shared/components/src/components/Modal/ContentModal.svelte new file mode 100644 index 0000000..c382689 --- /dev/null +++ b/shared/components/src/components/Modal/ContentModal.svelte @@ -0,0 +1,222 @@ +<script lang="ts"> + import { createEventDispatcher, onMount } from 'svelte'; + import CloseIcon from '@amp/web-app-components/assets/icons/close.svg'; + import { updateScrollAndWindowDependentVisuals } from '@amp/web-app-components/src/actions/updateScrollAndWindowDependentVisuals'; + import { focusNodeOnMount } from '@amp/web-app-components/src/actions/focus-node-on-mount'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let title: string | null; + export let subtitle: string | null; + export let text: string = null; + export let translateFn: (key: string) => string; + export let dialogTitleId: string | null = null; + + let contentContainerElement: HTMLElement; + let contentIsScrolling = false; + let hideGradient = false; + + const dispatch = createEventDispatcher(); + + const handleCloseButton = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + dispatch('close'); + }; + + onMount(() => { + // get initial state for hideGradient value, before user has scrolled + let { scrollHeight, offsetHeight } = contentContainerElement; + hideGradient = scrollHeight - offsetHeight === 0; + }); +</script> + +<div + data-testid="content-modal" + class="content-modal-container" + class:hide-gradient={hideGradient} + dir="auto" +> + <div class="button-container"> + <button + data-testid="content-modal-close-button" + class="close-button" + type="button" + on:click={handleCloseButton} + aria-label={translateFn('AMP.Shared.AX.Close')} + use:focusNodeOnMount + > + <CloseIcon data-testid="content-modal-close-button-svg" /> + </button> + {#if $$slots['button-container']} + <slot name="button-container" /> + {/if} + </div> + {#if title || subtitle} + <div + class="header-container" + class:content-is-scrolling={contentIsScrolling} + > + {#if title} + <h1 + id={dialogTitleId} + data-testid="content-modal-title" + class="title" + > + {title} + </h1> + {/if} + {#if subtitle} + <h2 data-testid="content-modal-subtitle" class="subtitle"> + {subtitle} + </h2> + {/if} + </div> + {/if} + {#if text || $$slots['content']} + <div + class="content-container" + bind:this={contentContainerElement} + use:updateScrollAndWindowDependentVisuals + on:scrollStatus={(e) => { + contentIsScrolling = e.detail.contentIsScrolling; + hideGradient = e.detail.hideGradient; + }} + > + {#if $$slots['content']} + <slot name="content" /> + {:else} + <p data-testid="content-modal-text"> + {@html sanitizeHtml(text)} + </p> + {/if} + </div> + {/if} +</div> + +<style lang="scss"> + .content-modal-container { + position: relative; + min-height: 230px; + max-height: calc(100vh - 160px); + height: auto; + display: flex; + flex-direction: column; + align-items: center; + max-width: 691px; + width: 80vw; + overflow: hidden; + background-color: var(--pageBG); + border-radius: var(--modalBorderRadius); + + @media (--range-xsmall-only) { + max-width: auto; + width: calc(100vw - 50px); + } + + &::after { + position: absolute; + bottom: 0; + height: 64px; + opacity: 1; + pointer-events: none; + transition-delay: 0s; + transition-duration: 300ms; + transition-property: height, width, background; + width: calc(100% - 60px); + content: ''; + background: linear-gradient( + to top, + var(--pageBG) 0%, + rgba(var(--pageBG-rgb), 0) 100% + ); + z-index: var(--z-default); + + @media (--range-xsmall-only) { + width: calc(100% - 40px); + } + } + } + + .header-container { + pointer-events: none; + position: sticky; + transition-delay: 0s; + transition-duration: 500ms; + transition-property: height, width; + width: 100%; + max-height: 120px; + padding-bottom: 22px; + z-index: var(--z-default); + } + + .content-is-scrolling { + box-shadow: 0 3px 5px var(--systemQuaternary); + } + + .button-container { + display: flex; + align-self: flex-start; + justify-content: space-between; + width: 100%; + } + + .close-button { + margin-top: 16px; + margin-bottom: 20px; + width: 18px; + height: 18px; + fill: var(--systemSecondary); + margin-inline-start: 20px; + } + + .title { + color: var(--systemPrimary); + padding: 0 30px; + font: var(--title-1-emphasized); + + @media (--range-xsmall-only) { + padding-inline-start: 20px; + padding-inline-end: 20px; + } + + @media (--small) { + font: var(--large-title-emphasized); + } + } + + .subtitle { + color: var(--systemSecondary); + padding: 0 30px; + font: var(--body); + + @media (--range-xsmall-only) { + padding-inline-start: 20px; + padding-inline-end: 20px; + } + } + + .content-container { + position: relative; + width: 100%; + height: calc(100% - 120px); + padding-bottom: 42px; + overflow-y: auto; + white-space: pre-wrap; + text-align: start; + font: var(--title-3-tall); + padding-inline-start: 30px; + padding-inline-end: 30px; + + @media (--range-xsmall-only) { + padding-inline-start: 20px; + padding-inline-end: 20px; + } + } + + .hide-gradient { + &::after { + opacity: 0; + } + } +</style> diff --git a/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherModal.svelte b/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherModal.svelte new file mode 100644 index 0000000..a248b55 --- /dev/null +++ b/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherModal.svelte @@ -0,0 +1,281 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import ChevronIcon from '@amp/web-app-components/assets/icons/chevron.svg'; + import CloseIcon from '@amp/web-app-components/assets/icons/close.svg'; + import { focusNodeOnMount } from '@amp/web-app-components/src/actions/focus-node-on-mount'; + import type { Region } from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types'; + import { updateScrollAndWindowDependentVisuals } from '@amp/web-app-components/src/actions/updateScrollAndWindowDependentVisuals'; + import LocaleSwitcherRegionList from './LocaleSwitcherRegionList.svelte'; + import LocaleSwitcherRegion from './LocaleSwitcherRegion.svelte'; + + const DEFAULT_LIST_MINIMUM_LENGTH = 6; + /** + * translate function provided by the parent app. + */ + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + export let regions: Region[]; + export let defaultRoute: string; + export let dialogTitleId: string | null = null; + + let contentIsScrolling = false; + let showDefaultList = true; + let seeAllRegion: Region; + let contentContainerElement: HTMLElement; + + // the default list for each region is what shows when you first open the modal + // this consists of each storefront in the default language, with no duplicate storefronts + const regionsDefaultList = regions.map(({ name, locales }) => { + return { + name, + locales: locales.filter((locale) => locale.isDefault), + }; + }); + + const dispatch = createEventDispatcher(); + + const getExpandedRegion = (region: Region) => + regions.find((expandedRegion) => expandedRegion.name === region.name); + + const handleSeeAll = (region: Region) => { + seeAllRegion = getExpandedRegion(region); + showDefaultList = false; + contentContainerElement.scroll(0, 0); + }; + + const handleCloseButton = () => { + dispatch('close'); + }; + + const handleBack = () => { + showDefaultList = true; + }; +</script> + +<div + data-testid="locale-switcher-modal-container" + class="locale-switcher-modal-container" +> + <button + data-testid="locale-switcher-modal-close-button" + class="close-button" + type="button" + on:click={handleCloseButton} + aria-label={translateFn('AMP.Shared.AX.Close')} + use:focusNodeOnMount + > + <CloseIcon data-testid="locale-switcher-modal-close-button-svg" /> + </button> + <div + class="header-container" + class:content-is-scrolling={contentIsScrolling} + > + <span + id={dialogTitleId} + data-testid="locale-switcher-modal-title" + class="title" + > + {translateFn('AMP.Shared.LocaleSwitcher.Heading')} + </span> + </div> + <div + class="region-container" + bind:this={contentContainerElement} + use:updateScrollAndWindowDependentVisuals + on:scrollStatus={(e) => + (contentIsScrolling = e.detail.contentIsScrolling)} + > + {#if showDefaultList} + {#each regionsDefaultList as region (region.name)} + <LocaleSwitcherRegion regionName={translateFn(region.name)}> + <button + slot="button" + class="see-all-button" + class:see-all-button-hidden={region.locales.length <= + DEFAULT_LIST_MINIMUM_LENGTH} + on:click={() => handleSeeAll(region)} + >{translateFn('AMP.Shared.LocaleSwitcher.SeeAll')} + </button> + <!-- If the default list is less than or equal to 6, pass in see all list instead for the default view --> + <LocaleSwitcherRegionList + slot="list" + regionList={region.locales.length <= + DEFAULT_LIST_MINIMUM_LENGTH + ? getExpandedRegion(region)?.locales + : region.locales} + {defaultRoute} + /> + </LocaleSwitcherRegion> + {/each} + {:else} + <button class="back-button" on:click={handleBack}> + <ChevronIcon class="back-chevron" aria-hidden="true" /> + {translateFn('AMP.Shared.LocaleSwitcher.Back')} + </button> + + <LocaleSwitcherRegion regionName={translateFn(seeAllRegion.name)}> + <LocaleSwitcherRegionList + slot="list" + regionList={seeAllRegion.locales} + {defaultRoute} + /> + </LocaleSwitcherRegion> + {/if} + </div> +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'amp/stylekit/core/fonts' as *; + @use 'amp/stylekit/modules/fontsubsets/core' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + .locale-switcher-modal-container { + position: relative; + min-height: 230px; + height: calc(100vh - 160px); + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; + background-color: var(--pageBG); + max-width: calc(100vw - 50px); + border-radius: $modal-border-radius; + + // Font subsets for Geos prevents `SF Pro` Web Font from being downloaded + // after `BlinkMacSystemFont` fails in Chrome. + font-family: font-family-locale(en-WW, geos); + + @each $lang, $font in font-family(geos) { + @if $lang != en-WW { + :global([lang]:lang(#{$lang})) & { + font-family: $font; + } + } + } + + @media (--small) { + width: 990px; + } + + @media (--xlarge) { + width: 1250px; + } + + &::after { + position: absolute; + bottom: 0; + height: 64px; + opacity: 1; + pointer-events: none; + transition-delay: 0s; + transition-duration: 300ms; + transition-property: height, width, background; + width: calc(100% - 40px); + content: ''; + background: linear-gradient( + to top, + var(--pageBG) 0%, + rgba(var(--pageBG-rgb), 0) 100% + ); + z-index: var(--z-default); + + @media (--small) { + width: calc(100% - 60px); + } + } + } + + .header-container { + pointer-events: none; + position: sticky; + transition-delay: 0s; + transition-duration: 500ms; + transition-property: height, width; + width: 100%; + padding-top: 54px; + padding-bottom: 32px; + max-height: 120px; + z-index: var(--z-default); + } + + .content-is-scrolling { + box-shadow: 0 3px 5px var(--systemQuaternary); + transition: box-shadow 0.2s ease-in-out; + } + + .close-button { + position: absolute; + top: 0; + margin: 16px 20px 10px; + width: 18px; + height: 18px; + align-self: flex-start; + fill: var(--systemSecondary); + } + + .title { + color: var(--systemPrimary); + text-align: center; + width: 100%; + display: block; + padding-inline-start: 20px; + padding-inline-end: 20px; + font: var(--title-1-emphasized); + + @media (--medium) { + font: var(--large-title-emphasized); + } + } + + .region-container { + position: relative; + height: calc(100% - 120px); + padding-bottom: 42px; + overflow-y: auto; + padding-inline-start: 20px; + padding-inline-end: 20px; + + @media (width >= 600px) { + padding-inline-start: 50px; + padding-inline-end: 50px; + } + } + + .back-button { + color: var(--keyColor); + margin-bottom: 16px; + display: flex; + align-items: center; + + :global(.back-chevron) { + height: 12px; + fill: var(--keyColor); + transform: rotate(180deg); + margin-inline-end: 5px; + + @include rtl { + transform: rotate(0deg); + } + } + } + + // shadow-DOM RTL styles + :global(:host([dir='rtl'])) { + :global(.back-chevron) { + transform: rotate(0deg); + } + } + + .see-all-button { + min-width: 42px; + color: var(--keyColor); + } + + .see-all-button-hidden { + display: none; + } +</style> diff --git a/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegion.svelte b/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegion.svelte new file mode 100644 index 0000000..3310e87 --- /dev/null +++ b/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegion.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + export let regionName: string; +</script> + +<div class="region-header"> + <h2> + {regionName} + </h2> + <slot name="button" /> +</div> +<slot name="list" /> + +<style lang="scss"> + .region-header { + padding-top: 13px; + padding-bottom: 20px; + border-top: 1px solid var(--labelDivider); + display: flex; + justify-content: space-between; + align-items: baseline; + } + + h2 { + margin-inline-end: 5px; + font: var(--title-2-emphasized); + } +</style> diff --git a/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegionList.svelte b/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegionList.svelte new file mode 100644 index 0000000..f123ce0 --- /dev/null +++ b/shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegionList.svelte @@ -0,0 +1,70 @@ +<script lang="ts"> + import type { Storefront } from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types'; + import { getStorefrontRoute } from '@amp/web-app-components/src/utils/getStorefrontRoute'; + + export let regionList: Storefront[]; + export let defaultRoute: string; + + const getRoute = (storefront: Storefront) => { + // the language param is only needed for non-default storefronts + return storefront.isDefault + ? getStorefrontRoute(defaultRoute, storefront.id) + : getStorefrontRoute( + defaultRoute, + storefront.id, + storefront.language, + ); + }; +</script> + +<ul> + {#each regionList as storefront} + <li> + <a href={getRoute(storefront)} data-testid="region-list-link"> + <span>{storefront.name}</span> + </a> + </li> + {/each} +</ul> + +<style lang="scss"> + ul, + li { + list-style-type: none; + margin: 0; + padding: 0; + } + + ul { + columns: 1 auto; + margin-bottom: 25px; + + @media (width >= 600px) { + columns: 3 auto; + } + + @media (--small) { + columns: 4 auto; + } + + @media (--large) { + columns: 5 auto; + } + + @media (--xlarge) { + columns: 6 auto; + } + } + + li { + padding-right: 40px; + padding-bottom: 26px; + display: inline-block; + width: 100%; + font: var(--callout); + + a { + --linkColor: var(--systemPrimary); + } + } +</style> diff --git a/shared/components/src/components/Modal/Modal.svelte b/shared/components/src/components/Modal/Modal.svelte new file mode 100644 index 0000000..a4fe147 --- /dev/null +++ b/shared/components/src/components/Modal/Modal.svelte @@ -0,0 +1,246 @@ +<script lang="ts"> + import { onMount, createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); + + export let modalTriggerElement: HTMLElement | null; + export let error: boolean = false; + export let dialogId: string = ''; + export let dialogClassNames: string = ''; + + /** + * Disable the background scrim for this modal. Used with fullscreen modal + * variants that don't apply a scrim while transitioning in or out of view. + */ + export let disableScrim: boolean = false; + + /** + * Whether to immediately display the modal when the component is mounted. + */ + export let showOnMount: boolean = false; + + /** + * If true, suppress the default `close` event fired by the native <dialog> element. + * Instead, a `close` event is dispatched to be handled by the consuming component. + * This is useful for modals that implement custom transitions and need to wait for + * transitions to end on child elements before <dialog> removes them from the DOM. + * + * Note that if this option is used, the consuming component *must* call `close()` + * on this component to properly close the modal! + */ + export let preventDefaultClose: boolean = false; + + /** + * ID for element that contains accessible modal title. + */ + export let ariaLabelledBy: string | null = null; + + /** + * Accessible modal title. Note that this should only be used when there is no element + * containing the modal title that can be associated using `ariaLabelledBy`. + */ + export let ariaLabel: string | null = null; + + let ariaHidden: boolean = true; + + let dialogElement: HTMLDialogElement; + let needsPolyfill: boolean = false; + let isDialogInShadow: boolean; + + export function showModal() { + // noscroll class ensures that when this component is in a shadow DOM context, + // the parent app can control the background scroll behavior + document.body.classList.add('noscroll'); + + /* + in non-shadow DOM contexts, add the dialog directly to the body to + avoid stacking context issues where the the dialog hides behind side nav on Music + see: https://github.com/GoogleChrome/dialog-polyfill#stacking-context + if the dialog is within the shadow DOM (being used as a web component) + do not append to the body and use showModal method to keep dialog within the shadow DOM + */ + if (needsPolyfill) { + isDialogInShadow = isInShadow(dialogElement); + if (!isDialogInShadow) { + document.body.appendChild(dialogElement); + } + } + ariaHidden = false; + dialogElement.showModal(); + } + + export function close() { + document.body.classList.remove('noscroll'); + + // in non-shadow DOM + polyfill instances we added the dialog + // directly to the body, this removes it + if (needsPolyfill && !isDialogInShadow) { + document.body.removeChild(dialogElement); + } + + ariaHidden = true; + dialogElement.close(); + modalTriggerElement?.focus(); + } + + function handleClose(e: Event) { + if (preventDefaultClose) { + e.preventDefault(); + } else { + close(); + } + dispatch('close'); + } + + function isInShadow(node: HTMLElement | ParentNode) { + for (; node; node = node.parentNode) { + if (node.toString() === '[object ShadowRoot]') { + return true; + } + } + return false; + } + + onMount(async () => { + // register polyfill for native <dialog> element if needed + needsPolyfill = !('showModal' in dialogElement); + if (needsPolyfill) { + const { default: dialogPolyfill } = await import('dialog-polyfill'); + dialogPolyfill.registerDialog(dialogElement); + dialogElement.classList.add('dialog-polyfill'); + } + + if (showOnMount) { + showModal(); + } + }); +</script> + +<!-- + @component + Dialog element wrapping a slot. + This component is multipurpose and should be used + anywhere a centered modal with a backdrop is needed + --> +<!-- svelte-ignore a11y-click-events-have-key-events --> +<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> +<dialog + data-testid="dialog" + class:error + class:no-scrim={disableScrim} + class={dialogClassNames} + class:needs-polyfill={needsPolyfill} + id={dialogId} + bind:this={dialogElement} + on:click|self={handleClose} + on:close={handleClose} + on:cancel={handleClose} + aria-labelledby={ariaLabelledBy} + aria-label={ariaLabel} + aria-hidden={ariaHidden} +> + <slot {handleClose} /> +</dialog> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + /* dialog polyfill styles need to be available + globally to avoid being stripped out */ + :global(.needs-polyfill) { + position: absolute; + left: 0; + right: 0; + width: fit-content; + height: fit-content; + margin: auto; + border: solid; + padding: 1em; + background: white; + color: black; + display: block; + + &:not([open]) { + display: none; + } + + & + .backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: rgba(0, 0, 0, 0.1); + } + + &._dialog_overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + &.fixed { + position: fixed; + top: 50%; + transform: translate(0, -50%); + } + } + + /* dialog polyfill sets position: absolute - this + needs to be reset to ensure the dialog does not + scroll to top on open */ + dialog:modal { + position: fixed; + } + + dialog { + width: var(--modalWidth, fit-content); + height: var(--modalHeight, fit-content); + max-width: var(--modalMaxWidth, initial); + max-height: var(--modalMaxHeight, initial); + border-radius: var(--modalBorderRadius, $modal-border-radius); + border: 0; + padding: 0; + color: var(--systemPrimary); + background: transparent; + + // Hide scrollbar while opening sliding modal + overflow: var(--modalOverflow, auto); + top: var(--modalTop, 0); + font: var(--body); + + &:focus { + outline: none; + } + + &::backdrop, + & + :global(.backdrop) /* for polyfill */ { + background-color: var(--modalScrimColor, rgba(0, 0, 0, 0.45)); + } + + // ::backdrop does not inherit from anything, so CSS properties must be set on + // it directly in order to have any effect. + &.no-scrim::backdrop, + &.no-scrim + :global(.backdrop) { + --modalScrimColor: transparent; + } + } + + // disable error modal animation until svelte animations are implemented + // rdar://92356192 (JMOTW: Error Modal: Use Svelte animations) + // $error-modal-duration: 0.275s; + // dialog.error { + // box-shadow: $dialog-inset-shadow, $dialog-shadow; + // animation-name: modalZoomIn; + // animation-duration: $error-modal-duration; + // animation-timing-function: cubic-bezier(0.27, 1.01, 0.43, 1.19); + // } + // @keyframes modalZoomIn { + // from { + // opacity: 0; + // transform: scale3d(0, 0, 0); + // } + // } +</style> diff --git a/shared/components/src/components/Navigation/Folder.svelte b/shared/components/src/components/Navigation/Folder.svelte new file mode 100644 index 0000000..2e1b15b --- /dev/null +++ b/shared/components/src/components/Navigation/Folder.svelte @@ -0,0 +1,277 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import type { Writable } from 'svelte/store'; + import type { + NavigationId, + BaseNavigationItem, + } from '@amp/web-app-components/src/types'; + import { + isSameTab, + getItemComponent, + } from '@amp/web-app-components/src/components/Navigation/utils'; + import allowDrag from '@amp/web-app-components/src/actions/allow-drag'; + import allowDrop from '@amp/web-app-components/src/actions/allow-drop'; + import { subscribeFolderOpenState } from '@amp/web-app-components/src/stores/navigation-folders-open'; + import ItemContent from './ItemContent.svelte'; + + const FOLDER_EXPAND_DELAY = 1000; + const dispatch = createEventDispatcher(); + + export let item: BaseNavigationItem; + export let isEditing: boolean = false; + export let currentTab: Writable<NavigationId | null>; + export let translateFn: (key: string) => string; + export let getItemDragData: (item: BaseNavigationItem) => any = null; + export let itemDragEnabled: + | boolean + | ((item: BaseNavigationItem) => boolean) = false; + export let itemDropEnabled: + | boolean + | ((item: BaseNavigationItem) => boolean) = false; + + let delayedExpandTimeoutId: ReturnType<typeof setTimeout>; + $: itemId = item.id.resourceId; + $: children = item.children; + $: hasChildren = children?.length > 0; + $: label = item.label ? item.label : translateFn(item.locKey); + $: isExpanded = subscribeFolderOpenState(itemId); + $: dragData = !!getItemDragData ? getItemDragData(item) : item; + $: isDragEnabled = + !!dragData && + (typeof itemDragEnabled === 'function' + ? itemDragEnabled(item) + : itemDragEnabled); + $: isDropEnabled = + typeof itemDropEnabled === 'function' + ? itemDropEnabled(item) + : itemDropEnabled; + + const toggleExpand = (): void => { + if (hasChildren) { + isExpanded.set(!$isExpanded); + } + }; + + const handleKeydown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + toggleExpand(); + break; + + case 'ArrowRight': + if (hasChildren && !$isExpanded) { + isExpanded.set(true); + e.preventDefault(); + e.stopPropagation(); + } + break; + + case 'ArrowLeft': + if (hasChildren && $isExpanded) { + isExpanded.set(false); + e.preventDefault(); + e.stopPropagation(); + } + break; + } + }; + + // Due to dragleave events being fired when dragging over child elements, + // we need to maintain a count of the number of elements we have entered + // within the folder to know when we have actually left the element. When + // enteredCount reaches 0, we know that we have finally left the outermost + // element. + // + // rdar://118572702 (Use event.relatedTarget to handle dragging playlists over folders) + // A more elegant solution could leverage event.relatedTarget to ignore + // dragleave events from children, but there is a Safari bug where + // relatedTarget is always null. + + let enteredCount = 0; + + const delayedExpand = (): void => { + enteredCount++; + + if (!$isExpanded && !delayedExpandTimeoutId) { + delayedExpandTimeoutId = setTimeout(() => { + isExpanded.set(true); + delayedExpandTimeoutId = null; + }, FOLDER_EXPAND_DELAY); + } + }; + + const cancelDelayedExpand = (): void => { + enteredCount--; + + if (enteredCount === 0 && delayedExpandTimeoutId) { + clearTimeout(delayedExpandTimeoutId); + delayedExpandTimeoutId = null; + } + }; +</script> + +<!-- svelte-ignore a11y-role-has-required-aria-props --> +<li + class="navigation-item navigation-item__folder" + data-testid="navigation-item__{item.id.type}" + class:navigation-item__folder--has-children={children} + class:folder-open={$isExpanded} + aria-expanded={$isExpanded} + role="treeitem" + tabindex="-1" + on:dragenter|capture|preventDefault={delayedExpand} + on:dragleave|capture|preventDefault={cancelDelayedExpand} + on:keydown|self={handleKeydown} +> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <span + class="navigation-item__folder-label" + class:drop-reset={!!isDropEnabled} + data-testid={itemId} + on:click|preventDefault={toggleExpand} + use:allowDrag={isDragEnabled && { + dragEnabled: true, + dragData, + usePlainDragImage: true, + }} + use:allowDrop={isDropEnabled && { + dropEnabled: true, + onDrop: (dropData) => dispatch('dropOnItem', { item, dropData }), + }} + > + {#if hasChildren} + <span + data-testid="folder-arrow-indicator" + class="folder-arrow-indicator" + role="presentation" + /> + {/if} + <ItemContent icon={item.icon} {label} /> + </span> + {#if hasChildren && $isExpanded} + <ul class="navigation-item__folder-list"> + {#each children as child} + {#if child.id.type === 'folder'} + <svelte:self + item={child} + {currentTab} + {getItemDragData} + {itemDragEnabled} + {itemDropEnabled} + {translateFn} + {isEditing} + on:selectItem + on:dropOnItem + /> + {:else} + <svelte:component + this={getItemComponent(child)} + item={child} + selected={isSameTab(child.id, $currentTab)} + {translateFn} + {isEditing} + getDragData={getItemDragData} + dragEnabled={itemDragEnabled} + dropEnabled={itemDropEnabled} + on:selectItem + on:drop={({ detail: dropData }) => + dispatch('dropOnItem', { item: child, dropData })} + /> + {/if} + {/each} + </ul> + {/if} +</li> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'amp/stylekit/core/mixins/line-clamp' as *; + @use 'amp/stylekit/core/mixins/overflow-bleed' as *; + + $menuicon-folder-transition: 0.3s transform ease; + + .navigation-item__folder { + --linkHoverTextDecoration: none; + border-radius: 6px; + margin-bottom: 2px; + padding: 4px; + position: relative; + + @media (--sidebar-visible) { + height: 32px; + } + + &.folder-open { + margin-bottom: 0; + padding-bottom: 0; + } + } + + .navigation-item__folder--has-children { + height: auto; + } + + .navigation-item__folder-label { + border-radius: 6px; + box-sizing: content-box; + display: flex; + align-items: center; + + @include overflow-bleed(3px); + + .navigation-item__folder--has-children & { + cursor: pointer; + } + + &:global(.is-drag-over) { + --drag-over-color: white; + --navigation-item-text-color: var(--drag-over-color); + --navigation-item-icon-color: var(--drag-over-color); + background-color: var(--selectionColor); + } + } + + .navigation-item__folder-list { + margin-inline-start: 8px; + margin-top: 4px; + } + + .folder-arrow-indicator::before { + content: ''; + width: 0; + height: 0; + display: inline-block; + position: absolute; + top: 16px; + border-style: solid; + border-top-width: 4px; + border-top-color: transparent; + border-bottom-width: 4px; + border-bottom-color: transparent; + transform: rotate(0deg); + transition: $menuicon-folder-transition; + border-inline-end-width: 0; + border-inline-end-color: transparent; + border-inline-start-width: 6px; + border-inline-start-color: var(--systemTertiary); + inset-inline-start: -12px; + + .folder-open & { + transform: rotate(90deg); + + @include rtl { + transform: rotate(-90deg); + } + } + + @media (--sidebar-visible) { + top: 12px; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } +</style> diff --git a/shared/components/src/components/Navigation/Item.svelte b/shared/components/src/components/Navigation/Item.svelte new file mode 100644 index 0000000..e10c604 --- /dev/null +++ b/shared/components/src/components/Navigation/Item.svelte @@ -0,0 +1,183 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import type { BaseNavigationItem } from '@amp/web-app-components/src/types'; + import allowDrag from '@amp/web-app-components/src/actions/allow-drag'; + import allowDrop, { + type DropOptions, + } from '@amp/web-app-components/src/actions/allow-drop'; + import ItemContent from './ItemContent.svelte'; + + export let item: BaseNavigationItem; + export let selected: boolean = false; + export let isEditing: boolean = false; + export let isChecked: boolean = false; + export let translateFn: (key: string) => string; + export let getDragData: (item: BaseNavigationItem) => any = null; + export let dragEnabled: boolean | ((item: BaseNavigationItem) => boolean) = + false; + export let dropEnabled: boolean | ((item: BaseNavigationItem) => boolean) = + false; + export let dropTargets: DropOptions['targets'] = null; + export let dropEffect: DataTransfer['dropEffect'] = null; + export let effectAllowed: DataTransfer['effectAllowed'] = null; + + $: label = item.label ? item.label : translateFn(item.locKey); + + $: dragData = !!getDragData ? getDragData(item) : item; + $: isDragEnabled = + !!dragData && + (typeof dragEnabled === 'function' ? dragEnabled(item) : dragEnabled); + $: isDropEnabled = + typeof dropEnabled === 'function' ? dropEnabled(item) : dropEnabled; + + const dispatch = createEventDispatcher(); + + function onChangeVisibility() { + dispatch('visibilityChangeItem'); + } + + const itemClicked = (): void => { + dispatch('selectItem', item); + }; +</script> + +<!-- TODO: rdar://97308317 (Investigate svelte AX warnings in shared components) --> +<!-- svelte-ignore a11y-role-supports-aria-props --> +<li + class="navigation-item navigation-item__{item.id.type}" + class:navigation-item--selected={selected} + class:is-editing={isEditing} + class:drop-reset={!!dropEnabled} + aria-selected={selected} + data-testid="navigation-item" + use:allowDrag={isDragEnabled && + !isEditing && { + dragEnabled: true, + dragData, + usePlainDragImage: true, + effectAllowed, + }} + use:allowDrop={isDropEnabled && + !isEditing && { + dropEnabled: true, + onDrop: (dropData) => dispatch('drop', dropData), + targets: dropTargets, + dropEffect, + }} +> + <slot> + {#if isEditing} + <label + for={item.id.type} + class="navigation-item__label" + data-testid="navigation-item-editing" + > + <ItemContent icon={item.icon} {label}> + <input + class="navigation-item__checkbox" + data-testid="navigation-item-editing-checkbox" + type="checkbox" + id={item.id.type} + checked={isChecked} + on:change={onChangeVisibility} + slot="prefix" + /> + </ItemContent> + </label> + {:else} + <a + href={item.url} + class="navigation-item__link" + role="button" + data-testid={item.id.resourceId || item.id.type} + aria-pressed={selected} + on:click|preventDefault={itemClicked} + > + <ItemContent icon={item.icon} {label} /> + </a> + {/if} + </slot> +</li> + +<style lang="scss"> + @use 'amp/stylekit/core/mixins/overflow-bleed' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + .navigation-item { + --linkHoverTextDecoration: none; + border-radius: 6px; + margin-bottom: 2px; + padding: 4px; + position: relative; + + &:last-child { + margin-bottom: 1px; + } + + &:not(.is-dragging) { + &:global(.is-drag-over) { + --drag-over-color: white; + --navigation-item-text-color: var(--drag-over-color); + --navigation-item-icon-color: var(--drag-over-color); + background-color: var(--selectionColor); + } + + &:global(.is-drag-over-top), + &:global(.is-drag-over-bottom) { + &::after { + content: ''; + position: absolute; + background-color: var(--keyColor); + width: 100%; + height: $drag-over-focus-size; + inset-inline-start: 0; + } + } + + &:global(.is-drag-over-top) { + &::after { + top: 0; + transform: translateY(calc(#{-$drag-over-focus-size} / 2)); + } + } + + &:global(.is-drag-over-bottom) { + &::after { + bottom: 0; + transform: translateY(calc(#{$drag-over-focus-size} / 2)); + } + } + } + + @media (--sidebar-visible) { + height: 32px; + + &.navigation-item__radio { + margin-bottom: 1px; + } + } + } + + .navigation-item--selected { + background-color: var(--navSidebarSelectedState); + } + + .navigation-item__search { + @media (--sidebar-visible) { + display: none; + } + } + + .navigation-item__link { + display: block; + box-sizing: content-box; + border-radius: inherit; + + @include overflow-bleed(3px); + } + + .navigation-item__checkbox { + accent-color: var(--keyColor); + margin-inline-end: 5px; + } +</style> diff --git a/shared/components/src/components/Navigation/ItemContent.svelte b/shared/components/src/components/Navigation/ItemContent.svelte new file mode 100644 index 0000000..4a4e69c --- /dev/null +++ b/shared/components/src/components/Navigation/ItemContent.svelte @@ -0,0 +1,71 @@ +<script lang="ts"> + import type { ComponentType } from 'svelte'; + + export let icon: ComponentType; + export let label: string; +</script> + +<div class="navigation-item__content"> + {#if $$slots['prefix']} + <slot name="prefix" /> + {/if} + + <span class="navigation-item__icon"> + <slot name="icon"> + <svelte:component this={icon} aria-hidden="true" /> + </slot> + </span> + + <span class="navigation-item__label"> + <slot name="label"> + {label} + </slot> + </span> +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'amp/stylekit/core/mixins/line-clamp' as *; + @use 'amp/stylekit/core/mixins/overflow-bleed' as *; + @use 'ac-sasskit/core/locale' as *; + + .navigation-item__content { + border-radius: inherit; + display: flex; + align-items: center; + width: 100%; + column-gap: 8px; + color: var(--navigation-item-text-color, var(--systemPrimary)); + + :global(.navigation-item--selected) & { + font: var(--title-2-emphasized); + + @media (--sidebar-visible) { + font: var(--title-3-medium); + } + } + } + + .navigation-item__icon { + line-height: 0; // Normalize line height + flex: 0 0; + flex-basis: var(--navigation-item-icon-size, 32px); + + :global(svg) { + width: 100%; + height: 100%; + fill: var(--navigation-item-icon-color, var(--keyColor)); + } + + @media (--sidebar-visible) { + flex-basis: var(--navigation-item-icon-size, 24px); + } + } + + .navigation-item__label { + flex: 1; + + @include line-clamp; + @include overflow-bleed(4px); + } +</style> diff --git a/shared/components/src/components/Navigation/MenuIcon.svelte b/shared/components/src/components/Navigation/MenuIcon.svelte new file mode 100644 index 0000000..9e9163f --- /dev/null +++ b/shared/components/src/components/Navigation/MenuIcon.svelte @@ -0,0 +1,178 @@ +<script lang="ts"> + import { + menuIsExpanded, + menuIsTransitioning, + } from '@amp/web-app-components/src/components/Navigation/store/menu-state'; + import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion'; + import { createEventDispatcher } from 'svelte'; + + export let translateFn: ( + key: string, + data?: Record<string | number, string>, + ) => string; + export let navigationId = ''; + + const OPEN_NAVIGATION_LABEL = translateFn('FUSE.AX.UI.Open.Navigation'); + const CLOSE_NAVIGATION_LABEL = translateFn('FUSE.AX.UI.Close.Navigation'); + const dispatch = createEventDispatcher(); + + // Helper vars for refocusing on menu button when the menu closes. + let menuWasExpanded = false; + let menuButton: HTMLButtonElement; + + $: ariaExpanded = $menuIsExpanded; + $: ariaLabel = ariaExpanded + ? CLOSE_NAVIGATION_LABEL + : OPEN_NAVIGATION_LABEL; + + $: if ($menuIsExpanded) { + menuWasExpanded = true; + } + + // Only focus the menu button if the menu was previously expanded and is now collapsed. + // This prevents the menu button from focusing on page mount. + $: if (!$menuIsExpanded && menuWasExpanded) { + menuButton?.focus(); + menuWasExpanded = false; + } + + function handleClick(): void { + // Only allow the menu to be expanded / contracted if a transition is not currently in flight. + if ($menuIsTransitioning) { + return; + } + + // Update the internal nav store + // Implicitly updates aria-expanded and aria-label + menuIsExpanded.set(!$menuIsExpanded); + + // dispatch event to parent app + dispatch('toggleExpansion', { + isMenuExpanded: ariaExpanded, + }); + + // If reduced motion is not preferred, the flag needs to be set + // that a transition is currently in flight. When reduced-motion is preferred, + // no transition occurs. + if (!$prefersReducedMotion) { + // Flag that the menu-transition is in flight. This gets unlocked + // by the <Navigation /> component as it has the longest duration + menuIsTransitioning.set(true); + } + } +</script> + +<button + data-testid="menuicon" + class="menuicon" + aria-controls={navigationId} + aria-label={ariaLabel} + aria-expanded={ariaExpanded} + on:click={handleClick} + bind:this={menuButton} +> + <span class="menuicon-bread menuicon-bread-top"> + <span class="menuicon-bread-crust menuicon-bread-crust-top" /> + </span> + <span class="menuicon-bread menuicon-bread-bottom"> + <span class="menuicon-bread-crust menuicon-bread-crust-bottom" /> + </span> +</button> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + $shared-transition-delay: 0.1008s; + $shared-transition-duration: 0.1806s; + $amp-nav-ease-blue: cubic-bezier(0.04, 0.04, 0.12, 0.96); + $amp-nav-ease-green: cubic-bezier(0.52, 0.16, 0.52, 0.84); + + .menuicon { + height: $global-header-mobile-contracted-height; + width: $global-header-mobile-contracted-height; + position: relative; + z-index: var(--z-default); + } + + .menuicon-bread { + height: 20px; + left: 13px; + pointer-events: none; + position: absolute; + top: 12px; + transition: transform $shared-transition-duration $amp-nav-ease-blue; + width: 20px; + z-index: var(--z-default); + + /* Make sure the crust elements are not clickable to ensure correct locking. */ + span { + pointer-events: none; + } + + [aria-expanded='true'] & { + height: 24px; + left: 10px; + top: 11px; + width: 24px; + // prettier-ignore + transition: transform 0.3192s $amp-nav-ease-blue $shared-transition-delay; + } + } + + [aria-expanded='true'] { + .menuicon-bread-top { + transform: rotate(-45deg); + } + + .menuicon-bread-bottom { + transform: rotate(45deg); + } + } + + .menuicon-bread-crust { + background: var(--keyColor); + border-radius: 1px; + display: block; + height: 2px; + position: absolute; + // prettier-ignore + transition: transform 0.1596s $amp-nav-ease-green $shared-transition-delay; + width: 20px; + z-index: var(--z-default); + + [aria-expanded='true'] & { + width: 24px; + transform: translateY(0); + transition: transform $shared-transition-duration $amp-nav-ease-blue; + } + } + + .menuicon-bread-crust-top { + top: 9px; + transform: translateY(-4px); + + [aria-expanded='true'] & { + top: 11px; + } + } + + .menuicon-bread-crust-bottom { + bottom: 9px; + transform: translateY(4px); + + [aria-expanded='true'] & { + bottom: 11px; + } + } + + // Remove transitions when user prefers reduced motion + @media (prefers-reduced-motion: reduce) { + .menuicon-bread, + .menuicon-bread-crust { + &, + [aria-expanded='true'] & { + transition: none; + } + } + } +</style> diff --git a/shared/components/src/components/Navigation/Navigation.svelte b/shared/components/src/components/Navigation/Navigation.svelte new file mode 100644 index 0000000..34b3daf --- /dev/null +++ b/shared/components/src/components/Navigation/Navigation.svelte @@ -0,0 +1,298 @@ +<script lang="ts"> + import { createEventDispatcher, afterUpdate } from 'svelte'; + import type { Writable } from 'svelte/store'; + import { + menuIsExpanded, + menuIsTransitioning, + } from '@amp/web-app-components/src/components/Navigation/store/menu-state'; + import type { NavigationId } from '@amp/web-app-components/src/types'; + import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types'; + import MenuIcon from './MenuIcon.svelte'; + import NavigationItems from './NavigationItems.svelte'; + import { allowDrop } from '@amp/web-app-components/src/actions/allow-drop'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + + const dispatch = createEventDispatcher(); + + /** + * The local storage key that contains the user-selected library items to show + * @type {string} + */ + export let visibilityPreferencesKey: string | null = null; + + /** + * A list of links to be in the navigation + * @type {Array<NavigationItem>} + */ + export let items: NavigationItem[]; + + /** + * A list of links to be in the library navigation + * @type {Array<NavigationItem>} + */ + export let libraryItems: NavigationItem[] = []; + + /** + * A list of personalized items in the navigation such as a user's playlists or stations + * @type {Array<NavigationItem>} + */ + export let personalizedItems: NavigationItem[] = []; + + /** + * Header to be used for the personalized items list + */ + export let personalizedItemsHeader: string = ''; + + /** + * translate function provided by the parent app. + */ + export let translateFn: (key: string) => string; + + /** + * The store containing the currently selected tab. + */ + export let currentTab: Writable<NavigationId | null>; + + /** + * Whether you should be able to drop on the library section + * @type {boolean} + */ + export let libraryDropEnabled: boolean = false; + + /** + * Boolean or method to indicate if it allows drop on navigation header. + * The header type can be passed in to have a conditional drop area. + * Use together with on:dropOnHeader + */ + export let headerDropEnabled: boolean | ((type: string) => boolean) = false; + + /** + * Function that maps the item to drag data. + * Uses the item by default when not set. + */ + export let getItemDragData: (item: NavigationItem) => any = null; + + /** + * Boolean or method to indicate if it allows items to be dragged. + * The item can be passed in to have conditional dragging. + * Use together with getItemDragData + */ + export let itemDragEnabled: boolean | ((item: NavigationItem) => boolean) = + false; + + /** + * Boolean or method to indicate if it allows drop on an item. + * The item can be passed in to have a conditional drop area. + * Use together with on:dropOnItem + */ + export let itemDropEnabled: boolean | ((item: NavigationItem) => boolean) = + false; + + const navigationId: string = 'navigation'; + + // If the viewport changes to show the sidebar while menu is expanded, update menu store. + // This ensures `aria-hidden="false"` on the main section and player bar. + $: if (!$sidebarIsHidden) { + $menuIsExpanded = false; + } + + let navigatableContainer: HTMLElement; +</script> + +<nav + data-testid="navigation" + class="navigation" + class:is-transitioning={$menuIsTransitioning} + class:is-expanded={$menuIsExpanded} + on:transitionend|self={() => ($menuIsTransitioning = false)} +> + <div class="navigation__header"> + {#if $sidebarIsHidden} + <MenuIcon {navigationId} {translateFn} on:toggleExpansion /> + <slot name="logo" /> + <slot name="auth" /> + {:else} + <slot name="logo" /> + <slot name="search" /> + {/if} + </div> + + <div + data-testid="navigation-content" + class="navigation__content" + id={navigationId} + aria-hidden={$sidebarIsHidden && !$menuIsExpanded ? 'true' : 'false'} + > + <!-- svelte-ignore a11y-no-static-element-interactions --> + <div + bind:this={navigatableContainer} + class="navigation__scrollable-container" + > + {#if typeof window === 'undefined' || navigatableContainer} + <NavigationItems + type="primary" + {items} + {translateFn} + {currentTab} + visibilityPreferencesKey={null} + header={null} + listGroupElement={navigatableContainer} + on:menuItemClick + /> + + {#if libraryItems.length > 0} + <div + use:allowDrop={libraryDropEnabled && { + dropEnabled: true, + onDrop: (dropData) => + dispatch('libraryDrop', dropData), + }} + data-testid="navigation-library-section" + > + <NavigationItems + type="library" + header={translateFn('AMP.Shared.Library')} + items={libraryItems} + listGroupElement={navigatableContainer} + {visibilityPreferencesKey} + {translateFn} + {currentTab} + {itemDragEnabled} + {itemDropEnabled} + on:dropOnItem + on:menuItemClick + /> + </div> + {/if} + + {#if personalizedItems.length > 0} + <NavigationItems + type="personalized" + header={personalizedItemsHeader} + items={personalizedItems} + visibilityPreferencesKey={null} + listGroupElement={navigatableContainer} + {translateFn} + {currentTab} + {getItemDragData} + {itemDragEnabled} + {itemDropEnabled} + {headerDropEnabled} + on:menuItemClick + on:dropOnItem + on:dropOnHeader + /> + {/if} + {/if} + <slot name="after-navigation-items" /> + </div> + + <div class="navigation__native-cta"> + <slot name="native-cta" /> + </div> + </div> +</nav> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + // Default Values + $amp-nav-element-transition: height 0.56s cubic-bezier(0.52, 0.16, 0.24, 1); + + .navigation { + width: 100%; + display: flex; + flex-direction: column; + z-index: var(--z-web-chrome); + + @media (--range-sidebar-hidden-down) { + height: $global-header-mobile-contracted-height; + position: fixed; + overflow: hidden; + background-color: var(--mobileNavigationBG); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + + &.is-expanded { + height: 100%; + } + + // The transition property should only be applied when the + // navigation is actively being set to expand / contract. + // This is to prevent unintended transitions when moving from + // `sidebar:visible` to `sidebar:hidden`. + &.is-transitioning { + transition: $amp-nav-element-transition; + } + + // Remove transition when user prefers reduced motion + @media (prefers-reduced-motion: 'reduce') { + transition: none; + } + } + + @media (--sidebar-visible) { + height: 100%; + position: relative; + background-color: var(--navSidebarBG); + box-shadow: none; + border-inline-end: 1px solid var(--labelDivider); + } + } + + .navigation__header { + display: grid; + + // Mobile styles -- horizontal icons + @media (--range-sidebar-hidden-down) { + grid-template-columns: repeat(3, 1fr); + align-items: center; + margin-inline-start: 12px; + margin-inline-end: 11px; + + // Position each child correctly relative to grid cell + & > :global(:nth-child(1)) { + justify-self: start; + } + + & > :global(:nth-child(2)) { + justify-self: center; + } + + & > :global(:nth-child(3)) { + justify-self: end; + } + } + + // Desktop styles -- stacked logo + search + @media (--sidebar-visible) { + :global(.search-input-wrapper) { + min-height: $web-search-input-height; + } + } + } + + .navigation__content { + display: flex; + flex-direction: column; + overflow: hidden; + + // Explicitly set sidebar content container width to include border, per spec + @media (--sidebar-visible) { + width: var(--web-navigation-width); + flex: 1; + } + } + + .navigation__scrollable-container { + overflow-y: auto; + scroll-behavior: smooth; + + @media (--range-sidebar-hidden-down) { + padding-top: 23px; + } + + @media (--sidebar-visible) { + flex: 1; // Push CTA to bottom of sidebar + } + } +</style> diff --git a/shared/components/src/components/Navigation/NavigationItems.svelte b/shared/components/src/components/Navigation/NavigationItems.svelte new file mode 100644 index 0000000..5d9dcf7 --- /dev/null +++ b/shared/components/src/components/Navigation/NavigationItems.svelte @@ -0,0 +1,281 @@ +<script lang="ts"> + import { createEventDispatcher, onMount } from 'svelte'; + import type { Writable } from 'svelte/store'; + import type { NavigationId } from '@amp/web-app-components/src/types'; + import { menuIsExpanded } from '@amp/web-app-components/src/components/Navigation/store/menu-state'; + import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types'; + import { + isSameTab, + getItemComponent, + } from '@amp/web-app-components/src/components/Navigation/utils'; + import Folder from './Folder.svelte'; + import { shouldShowNavigationItem } from '@amp/web-app-components/src/utils/should-show-navigation-item'; + import allowDrop from '@amp/web-app-components/src/actions/allow-drop'; + import { listKeyboardAccess } from '@amp/web-app-components/src/actions/list-keyboard-access'; + + let isEditing = false; + + /** + * The local storage key with the prefs of what library items to be visible + */ + export let visibilityPreferencesKey: string | null = null; + + /** + * The navigation tabs to display. + */ + export let items: NavigationItem[]; + + /** + * The type of navigation item to display + */ + export let type: string | null = null; + + /** + * Retrieve UI translations for a given localization key. + */ + export let translateFn: (key: string) => string; + + /** + * The navigation title header -- this appears right over the items. + */ + export let header: string | null; + + /** + * The store containing the currently selected tab. + */ + export let currentTab: Writable<NavigationId | null>; + + /** + * Boolean or method to indicate if it allows drop on header + */ + export let headerDropEnabled: boolean | ((type: string) => boolean) = false; + + /** + * Optional function to map item to drag data + */ + export let getItemDragData: (item: NavigationItem) => any = null; + + /** + * Boolean or method to indicate if it allows dragging an item + */ + export let itemDragEnabled: boolean | ((item: NavigationItem) => boolean) = + false; + + /** + * Boolean or method to indicate if it allows drop on an item + */ + export let itemDropEnabled: boolean | ((item: NavigationItem) => boolean) = + false; + + export let listGroupElement: HTMLElement = null; + + const dispatch = createEventDispatcher(); + + const setCurrentActiveItem = (event: CustomEvent<{ id: NavigationId }>) => { + currentTab.set(event.detail.id); + + // Always immediately close the menu (in XS breakpoint) + menuIsExpanded.set(false); + + dispatch('menuItemClick', event.detail); + }; + + $: ariaRole = items.find((item) => item?.children) ? 'tree' : null; + $: containingClassName = type ? `navigation-items--${type}` : ''; + $: isHeaderDropEnabled = + typeof headerDropEnabled === 'function' + ? headerDropEnabled(type) + : headerDropEnabled; + + function toggleEdit() { + isEditing = !isEditing; + } + + let data = {}; + + function visibilityChangeItem(storageKey: string) { + const currentSetting = data[storageKey]; + data = { ...data, [storageKey]: !currentSetting }; + localStorage.setItem(visibilityPreferencesKey, JSON.stringify(data)); + } + + function displayOptions() { + const current = localStorage?.getItem(visibilityPreferencesKey); + + if (current) { + data = JSON.parse(current); + } else { + data = Object.fromEntries( + items.map(({ storageKey }) => [storageKey, true]), + ); + localStorage?.setItem( + visibilityPreferencesKey, + JSON.stringify(data), + ); + } + } + + onMount(() => { + if (visibilityPreferencesKey) { + displayOptions(); + } + }); +</script> + +<div + data-testid={`navigation-items-${type}`} + class={`navigation-items ${containingClassName}`} +> + {#if header} + <div + aria-hidden="true" + class="navigation-items__header" + class:drop-reset={isHeaderDropEnabled} + data-testid={`navigation-items-header`} + use:allowDrop={isHeaderDropEnabled && + !isEditing && { + dropEnabled: true, + onDrop: (dropData) => + dispatch('dropOnHeader', { type, dropData }), + }} + > + <span> + {header} + </span> + {#if visibilityPreferencesKey} + <button + data-testid="navigation-items__toggler" + on:click={toggleEdit} + class="edit-toggle-button" + class:is-editing={isEditing} + > + {#if isEditing} + <span data-testid="navigation-items__editing-done" + >{translateFn('AMP.Shared.Done')}</span + > + {:else} + <span data-testid="navigation-items__editing-edit" + >{translateFn('AMP.Shared.Edit')}</span + > + {/if} + </button> + {/if} + </div> + {/if} + + <ul + role={ariaRole} + aria-label={header} + class="navigation-items__list" + use:listKeyboardAccess={{ + listItemClassNames: + 'navigation-item__link, navigation-item__folder, click-action', + isRoving: true, + listGroupElement: listGroupElement, + }} + > + {#each items as item (item.id)} + {#if item.id.type === 'folder'} + <Folder + item={{ ...item }} + {isEditing} + {currentTab} + {translateFn} + {getItemDragData} + {itemDragEnabled} + {itemDropEnabled} + on:selectItem={setCurrentActiveItem} + on:dropOnItem + /> + {:else if shouldShowNavigationItem(visibilityPreferencesKey, isEditing, data, item.storageKey)} + <svelte:component + this={getItemComponent(item)} + {item} + selected={isSameTab(item.id, $currentTab)} + on:selectItem={setCurrentActiveItem} + isChecked={data && data[item.storageKey]} + {isEditing} + {translateFn} + getDragData={getItemDragData} + dragEnabled={itemDragEnabled} + dropEnabled={itemDropEnabled} + on:drop={({ detail: dropData }) => + dispatch('dropOnItem', { item, dropData })} + on:visibilityChangeItem={() => + visibilityChangeItem(item.storageKey)} + /> + {/if} + {/each} + </ul> +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use 'amp/stylekit/core/mixins/overflow-bleed' as *; + + .navigation-items { + grid-area: navigation-items; + padding-top: 7px; + } + + .navigation-items--primary { + padding-top: 9px; + } + + .navigation-items--library { + grid-area: library-navigation-items; + } + + .navigation-items--personalized { + grid-area: personalized-navigation-items; + } + + .navigation-items__header { + color: var(--systemSecondary); + padding: 15px 26px 3px; + display: flex; + justify-content: space-between; + font: var(--body-emphasized); + + @media (--sidebar-visible) { + margin: 0 20px -3px; + padding: 4px 6px; + border-radius: 6px; + font: var(--footnote-emphasized); + } + + &:global(.is-drag-over) { + --drag-over-color: white; + color: var(--drag-over-color); + background-color: var(--selectionColor); + } + } + + .edit-toggle-button { + color: var(--systemPrimary); + + @media (--sidebar-visible) { + opacity: 0; + transition: var(--global-transition); + + &:focus { + opacity: 1; + } + } + } + + .edit-toggle-button.is-editing, + .navigation-items__header:hover .edit-toggle-button { + opacity: 1; + } + + .navigation-items__list { + font: var(--title-2); + padding: 3px 26px; + + @media (--sidebar-visible) { + font: var(--title-3); + padding: 0 $web-navigation-inline-padding 9px; + } + } +</style> diff --git a/shared/components/src/components/Navigation/store/menu-state.ts b/shared/components/src/components/Navigation/store/menu-state.ts new file mode 100644 index 0000000..9f36519 --- /dev/null +++ b/shared/components/src/components/Navigation/store/menu-state.ts @@ -0,0 +1,4 @@ +import { writable } from 'svelte/store'; + +export const menuIsExpanded = writable(false); +export const menuIsTransitioning = writable(false); diff --git a/shared/components/src/components/Navigation/utils.ts b/shared/components/src/components/Navigation/utils.ts new file mode 100644 index 0000000..87c8e59 --- /dev/null +++ b/shared/components/src/components/Navigation/utils.ts @@ -0,0 +1,27 @@ +import type { ComponentType } from 'svelte'; +import type { + BaseNavigationItem, + NavigationId, +} from '@amp/web-app-components/src/types'; +import Item from './Item.svelte'; + +export function isSameTab( + a: NavigationId | null, + b: NavigationId | null, +): boolean { + if (a === null || b === null) { + return false; + } + + // Need deep object equality for things like + // { kind: 'playlist', id: '123' } + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +export function getItemComponent(item: BaseNavigationItem): ComponentType { + return item.component ?? Item; +} diff --git a/shared/components/src/components/Rating/Rating.svelte b/shared/components/src/components/Rating/Rating.svelte new file mode 100644 index 0000000..de8e478 --- /dev/null +++ b/shared/components/src/components/Rating/Rating.svelte @@ -0,0 +1,141 @@ +<script lang="ts"> + import type { RatingCountsList } from './types'; + import { calculatePercentages } from './utils'; + import FilledStarIcon from '@amp/web-app-components/assets/icons/star-filled.svg'; + + /** + * @name Rating + * + * @description + * This implements the standard rating lockup showing aggregate ratings + * + * Design: + * https://pd-hi.apple.com/viewvc/Common/Modules/macOS/Podcasts/Lockups/Review%20Lockup.png?revision=57299 + * + * Aria Discussions: + * https://quip-apple.com/yvZaAbJMnAK0#JeB9CAOHPMd + * + * POTW difference: + * No write a review on the web + */ + + export let averageRating: number | string; + export let ratingCount: number; + export let ratingCountText: string; + export let ratingCountsList: RatingCountsList; + export let totalText: string; + + $: ratingPercentList = calculatePercentages(ratingCountsList, ratingCount); +</script> + +<div class="amp-rating" data-testid="rating-component"> + <div class="stats" aria-label={`${averageRating} ${totalText}`}> + <div class="stats__main" data-testid="amp-rating__average-rating"> + {averageRating} + </div> + <div class="stats__total" data-testid="amp-rating__total-text"> + {totalText} + </div> + </div> + <div class="numbers"> + <div class="numbers__star-graph"> + {#each ratingPercentList as value, i} + <div + class={`numbers__star-graph__row row-${i}`} + aria-label={`${5 - i} star, ${value}%`} + > + <!-- TODO: rdar://79873131 (Localize Aria Label in Rating Shared Component) --> + <div class="numbers__star-graph__row__stars"> + <!-- In order to display the 5 stars to 1 stars we use the 5 - index as 0 index means 1 star and so on --> + {#each { length: 5 - i } as _} + <div class="star"><FilledStarIcon /></div> + {/each} + </div> + <div class="numbers__star-graph__row__bar"> + <div + class="numbers__star-graph__row__bar__foreground" + style={`width: ${value}%`} + data-testid={`star-row-${5 - i}`} + /> + </div> + </div> + {/each} + </div> + <div class="numbers__count" data-testid="amp-rating__rating-count-text"> + {ratingCountText} + </div> + </div> +</div> + +<style lang="scss"> + .amp-rating { + display: flex; + } + + .stats { + margin-right: 10px; + flex: 0 80px; + } + + .stats__main { + font-size: 50px; + font-weight: bold; + display: flex; + justify-content: center; + } + + .stats__total { + display: flex; + justify-content: center; + color: var(--systemSecondary-text); + font: var(--body-emphasized); + } + + .numbers { + width: 100%; + } + + .numbers__count { + display: flex; + align-items: flex-end; + justify-content: flex-end; + color: var(--systemSecondary-text); + } + + .numbers__star-graph { + margin-top: 12px; + line-height: 9px; + } + + .numbers__star-graph__row { + display: flex; + width: 100%; + } + + .numbers__star-graph__row__stars { + display: flex; + min-width: 45px; + font-size: 8px; + justify-content: flex-end; + margin-right: 6px; + + & :global(.star) { + fill: var(--systemSecondary); + width: 8px; + height: 8px; + } + } + + .numbers__star-graph__row__bar { + height: 2px; + width: 100%; + background: var(--systemQuaternary); + margin-top: 3px; + } + + .numbers__star-graph__row__bar__foreground { + height: 2px; + background: var(--ratingBarColor, --systemSecondary); + max-width: 100%; + } +</style> diff --git a/shared/components/src/components/Rating/utils.ts b/shared/components/src/components/Rating/utils.ts new file mode 100644 index 0000000..cb909b4 --- /dev/null +++ b/shared/components/src/components/Rating/utils.ts @@ -0,0 +1,10 @@ +import type { RatingCountsList } from './types'; + +// eslint-disable-next-line import/prefer-default-export +export const calculatePercentages = ( + ratingValues: RatingCountsList, + totalCount: number, +): RatingCountsList => + ratingValues?.map((value: number) => + Math.round((value / totalCount) * 100), + ) || []; diff --git a/shared/components/src/components/SearchInput/SearchInput.svelte b/shared/components/src/components/SearchInput/SearchInput.svelte new file mode 100644 index 0000000..1c34ef9 --- /dev/null +++ b/shared/components/src/components/SearchInput/SearchInput.svelte @@ -0,0 +1,530 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import type { Writable } from 'svelte/store'; + import type { NavigationId } from '@amp/web-app-components/src/types'; + import clickOutside from '@amp/web-app-components/src/actions/click-outside'; + import SearchSuggestions from '@amp/web-app-components/src/components/SearchSuggestions/SearchSuggestions.svelte'; + import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types'; + import { + ClearEventLocation, + SEARCH_EVENTS, + } from '@amp/web-app-components/src/constants'; + import { getUpdatedFocusedIndex } from '@amp/web-app-components/src/utils/getUpdatedFocusedIndex'; + import { debounce } from '@amp/web-app-components/src/utils/debounce'; + import type { + HighlightedSearchSuggestion, + SearchSuggestion, + } from '@amp/web-app-components/src/utils/processTextSearchSuggestion'; + import SearchIcon from '@amp/web-app-components/assets/icons/search.svg'; + + const { + SEARCH_INPUT_HAS_FOCUS, + MAKE_SEARCH_QUERY_FROM_SUGGESTION, + MAKE_SEARCH_QUERY_FROM_INPUT, + CLICKED_OUTSIDE_SUGGESTIONS, + CLICKED_OUTSIDE, + RESET_SEARCH_INPUT, + MENU_ITEM_CLICK, + SHOW_SEARCH_SUGGESTIONS, + } = SEARCH_EVENTS; + + $: debouncedHandleSearchInput = debounce(handleSearchInput, 100); + + /** + * The translate fn to be used to handle localization + * @type {function} + */ + export let translateFn: (key: string) => string; + + /** + * The handler to be executed that retrieves suggestions for a given term + * @type {function} + */ + export let getSuggestionsForPartialTerm: ( + partialTerm: string, + ) => Promise<SearchSuggestion[]> = async () => []; + + /** + * The store containing the currently selected tab. + */ + export let currentTab: Writable<NavigationId | null>; + + /** + * The pre-filled value of the text field + */ + export let defaultValue: string | null = null; + + /** + * The menu item that should be selected when a search is performed or the + * search field receives focus while not on this item. + */ + export let menuItem: NavigationItem; + + /** + * Optional argument to disable search suggestions completely + */ + export let hideSuggestions = false; + + let suggestions = []; + let cachedSuggestions = []; + let partialTerm = !!defaultValue ? defaultValue : ''; + let focusedSearchSuggestionIndex = null; + let searchInputElement: HTMLInputElement; + let showSuggestion = false; + let showCancelButton = false; + + $: showSuggestion = suggestions?.length > 0; + $: handleShowSuggestion(showSuggestion); + + const dispatch = createEventDispatcher<{ + resetSearchInput: null; // no details returned + menuItemClick: NavigationItem; + searchInputHasFocus: null; // no details returned + makeSearchQueryFromInput: { term: string }; + // Unfortunately SearchSuggestions uses Array<any> so no way to fully type this. + // rdar://137049269 ((Shared/Components) Create Types for SearchSuggestions component) + makeSearchQueryFromSuggestion: { suggestion: any }; + clickedOutsideSuggestions: null; // no details returned + clickedOutside: null; // no details returned + clear: { from: ClearEventLocation }; + showSearchSuggestions: { showSearchSuggestions: boolean }; + }>(); + + function resetSearchInputState() { + searchInputElement.value = ''; + partialTerm = ''; + suggestions = []; + cachedSuggestions = []; + focusedSearchSuggestionIndex = null; + dispatch(RESET_SEARCH_INPUT); + } + + /** + * We use a click focus here (instead of input focus) as a + * lighter touch way to detect interaction with the search input. + * + * See additional explanation here: + * rdar://83511986 (JMOTW AX Music: Focussing on Search Field should not trigger a Context Change in Routing) + */ + function handleSearchInputClickFocus() { + showCancelButton = true; + const currentTerm = searchInputElement.value; + if (currentTerm === partialTerm && cachedSuggestions.length > 0) { + suggestions = cachedSuggestions; + cachedSuggestions = []; + } + + // Only switch to the search tab if we aren't already on it + if ($currentTab !== menuItem.id) { + currentTab.set(menuItem.id); + dispatch(MENU_ITEM_CLICK, menuItem); + } + + dispatch(SEARCH_INPUT_HAS_FOCUS); + } + + function handleSearchInputSubmit(event: SubmitEvent) { + const term = searchInputElement.value; + event.preventDefault(); + + if (term) { + dispatch(MAKE_SEARCH_QUERY_FROM_INPUT, { + term, + }); + + // Submitting a search always goes to the search tab + currentTab.set(menuItem.id); + + // Cache the current list of suggestions in case searchInputElement + // becomes focused again. + cachedSuggestions = suggestions; + suggestions = []; + focusedSearchSuggestionIndex = null; + + // Also hides the suggestions if visible + searchInputElement.blur(); + } + } + + function onSearchSuggestionChosen(suggestion: HighlightedSearchSuggestion) { + dispatch(MAKE_SEARCH_QUERY_FROM_SUGGESTION, { suggestion }); + + // Clicking on a search suggestion always goes to the search tab + currentTab.set(menuItem.id); + + resetSearchInputState(); + searchInputElement.value = suggestion.displayTerm; + } + + function onSearchSuggestionFocused(index: number) { + focusedSearchSuggestionIndex = index; + } + + function containerHandleKeyDown(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowDown': + case 'ArrowUp': + event.preventDefault(); + break; + } + } + + function containerHandleKeyUp(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowDown': + focusedSearchSuggestionIndex = getUpdatedFocusedIndex( + 1, + focusedSearchSuggestionIndex, + suggestions.length, + ); + break; + + case 'ArrowUp': + focusedSearchSuggestionIndex = getUpdatedFocusedIndex( + -1, + focusedSearchSuggestionIndex, + suggestions.length, + ); + break; + + case 'Escape': + resetSearchInputState(); + break; + + case 'Tab': + case 'Control': + case 'Alt': + case 'Meta': + case 'Shift': + case ' ': // Spacebar + // Don't do anything for remaining navigation keys. + break; + + default: + // If this event is not a navigational key, or not a Tab the focus is returned to the input + // allowing the user to type with the this key stroke. This is necesasry because + // VoiceOver first lands on the container and not on the input field. + searchInputElement.focus(); + } + + event.preventDefault(); + } + + async function handleSearchInput(input: HTMLInputElement) { + const searchInput = input ?? searchInputElement; + partialTerm = searchInput.value; + + if (!partialTerm) { + suggestions = []; + return; + } + + let _suggestions = await getSuggestionsForPartialTerm(partialTerm); + cachedSuggestions = _suggestions; + + // rdar://93009223 (JMOTW: Hitting enter in search field before suggestions loads leaves suggestions stuck) + // + // We only want to show suggestions here if the input is focused. + // Without this condition, suggestions will show up after enter is pressed if + // it takes too long for the api to return + if (document.activeElement === searchInput) { + suggestions = _suggestions; + cachedSuggestions = []; + } + } + + /** + * We don't want `menuItemClick` to also get debounced + * Extrapolating logic here to handle the route switch as well as the input delay + * + * rdar://83511986 (AX Music: Focussing on Search Field should not trigger a Context Change in Routing) + * + * TODO: we currently have no way to re-render the search landing page if the currently selected tab + * is already on the search tab. The best solution (as of now) to re-render the search landing page + * is to check if the input value is empty. + * + * rdar://91073241 (JMOTW: Search - Find a way to stop re-renders of search landing page) + */ + function handleSearchInputActivity(e: Event) { + if ( + !(e instanceof InputEvent) && + (e.target as HTMLInputElement).value === '' + ) { + dispatch('clear', { from: ClearEventLocation.Input }); + } + const shouldDispatchMenuClick = + $currentTab !== menuItem.id || searchInputElement.value === ''; + + // From svelte docs: + // The store value gets set to the value of the argument if + // the store value is not already equal to it. + // https://svelte.dev/docs#run-time-svelte-store-writable + currentTab.set(menuItem.id); + + if (shouldDispatchMenuClick) { + menuItem.opaqueData = () => ({ from: 'searchInputClear' }); + dispatch(MENU_ITEM_CLICK, menuItem); + } + + debouncedHandleSearchInput(e.target as HTMLInputElement); + } + + function handleClickOutside(event: Event) { + const element = (event.target as HTMLElement) || null; + + const eventPath = event.composedPath ? event.composedPath() : []; + const didEventHappenInContextMenu = eventPath.some( + (item) => + 'nodeName' in item && item.nodeName === 'AMP-CONTEXTUAL-MENU', + ); + + // dont close menu if interacting with context menu + if ( + (element && element.nodeName === 'AMP-CONTEXTUAL-MENU') || + didEventHappenInContextMenu + ) { + return; + } + + if (suggestions.length > 0) { + // Cache the current list of suggestions in case searchInputElement + // becomes focused again. + cachedSuggestions = suggestions; + + // Clear out the suggestions so the suggestions disappear + suggestions = []; + + dispatch(CLICKED_OUTSIDE_SUGGESTIONS); + } + + showCancelButton = false; + dispatch(CLICKED_OUTSIDE); + } + + function handleShowSuggestion(curShowSuggestions: boolean) { + dispatch(SHOW_SEARCH_SUGGESTIONS, { + showSearchSuggestions: curShowSuggestions, + }); + } + + function handleCancelButton() { + showCancelButton = false; + searchInputElement.value = ''; + dispatch('clear', { from: ClearEventLocation.Cancel }); + } +</script> + +<div + data-testid="amp-search-input" + aria-controls="search-suggestions" + aria-expanded={suggestions && suggestions.length > 0} + aria-haspopup="listbox" + aria-owns="search-suggestions" + class="search-input-container" + tabindex="-1" + role={showSuggestion ? 'combobox' : ''} + use:clickOutside={handleClickOutside} + on:keydown={containerHandleKeyDown} + on:keyup={containerHandleKeyUp} +> + <div class="flex-container"> + <form + role="search" + id="search-input-form" + on:submit={handleSearchInputSubmit} + > + <SearchIcon class="search-svg" aria-hidden="true" /> + + <input + value={defaultValue} + aria-activedescendant={Number.isInteger( + focusedSearchSuggestionIndex, + ) && focusedSearchSuggestionIndex >= 0 + ? `search-suggestion-${focusedSearchSuggestionIndex}` + : undefined} + aria-autocomplete="list" + aria-multiline="false" + aria-controls="search-suggestions" + placeholder={translateFn('AMP.Shared.SearchInput.Placeholder')} + spellcheck={false} + autocomplete="off" + autocorrect="off" + autocapitalize="off" + type="search" + class="search-input__text-field" + bind:this={searchInputElement} + data-testid="search-input__text-field" + on:input={handleSearchInputActivity} + on:click={handleSearchInputClickFocus} + /> + </form> + + {#if showCancelButton} + <div + class="search-input__cancel-button-container" + data-testid="search-input__cancel-button-container" + > + <button + data-testid="search-input__cancel-button" + on:click={handleCancelButton} + aria-label={translateFn('FUSE.Search.Cancel')} + > + {translateFn('FUSE.Search.Cancel')} + </button> + </div> + {/if} + </div> + + <div data-testid="search-scope-bar"><slot name="searchScopeBar" /></div> + + <!-- https://github.com/sveltejs/svelte/issues/5604 --> + {#if !hideSuggestions && suggestions && suggestions.length > 0} + {#if $$slots['suggestion']} + <SearchSuggestions + on:suggestionClicked={(e) => + onSearchSuggestionChosen(e.detail.suggestion)} + on:suggestionFocused={(e) => + onSearchSuggestionFocused(e.detail.index)} + {suggestions} + focusedSuggestionIndex={focusedSearchSuggestionIndex} + {translateFn} + > + <svelte:fragment slot="suggestion" let:suggestion> + <slot name="suggestion" {suggestion} /> + </svelte:fragment> + </SearchSuggestions> + {:else} + <SearchSuggestions + on:suggestionClicked={(e) => + onSearchSuggestionChosen(e.detail.suggestion)} + on:suggestionFocused={(e) => + onSearchSuggestionFocused(e.detail.index)} + {suggestions} + focusedSuggestionIndex={focusedSearchSuggestionIndex} + {translateFn} + /> + {/if} + {/if} +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use '@amp/web-shared-styles/app/core/mixins/focus' as *; + + $search-input-text-height: 32px; + $search-svg-size-hide-sidebar: 12px; + + .search-input-container { + @media (--sidebar-visible) { + position: relative; + z-index: var(--z-default); + } + + @media (--range-sidebar-hidden-down) { + width: 100%; + } + + :global(.search-svg) { + width: 16px; + height: 16px; + top: 10px; + bottom: 10px; + position: absolute; + fill: var(--searchBoxIconFill); + inset-inline-start: 10px; + z-index: var(--z-default); + + @media (--sidebar-visible) { + width: $search-svg-size-hide-sidebar; + height: $search-svg-size-hide-sidebar; + } + } + + :global(.search-suggestion-svg) { + fill: var(--searchBoxIconFill); + } + } + + .search-input__text-field { + background-color: var(--pageBG); + border-radius: 4px; + border-style: solid; + border-width: 1px; + border-color: var(--searchBarBorderColor); + color: var(--systemPrimary-vibrant); + font-size: 12px; + font-weight: 400; + height: $search-input-text-height; + letter-spacing: 0; + line-height: 1.25; + padding-top: 6px; + padding-bottom: 5px; + width: 100%; + padding-inline-end: 5px; + + @media (--range-sidebar-hidden-down) { + height: 38px; + border-radius: 9px; + padding-inline-start: 34px; + font: var(--title-3-tall); + font-size: 16px; + } + + @media (--sidebar-visible) { + padding-inline-start: 28px; + } + } + + input::-webkit-search-decoration, + input::-webkit-search-results-decoration { + appearance: none; + } + + input::placeholder { + color: var(--systemTertiary-vibrant); + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary-vibrant); + } + } + + input:focus { + @include focus-shadow; + } + + input::-webkit-search-cancel-button { + $cancelButtonSize: 14px; + appearance: none; + background-position: center; + background-repeat: no-repeat; + background-size: $cancelButtonSize $cancelButtonSize; + height: $cancelButtonSize; + width: $cancelButtonSize; + background-image: url('/assets/icons/sidebar-searchfield-close-on-light.svg'); + + @media (prefers-color-scheme: dark) { + background-image: url('/assets/icons/sidebar-searchfield-close-on-dark.svg'); + } + } + + .search-input__cancel-button-container { + align-self: center; + color: var(--keyColor); + font: var(--title-3-tall); + margin-inline-start: 14px; + + @media (--sidebar-visible) { + display: none; + } + } + + .flex-container { + @media (--range-sidebar-hidden-down) { + display: flex; + + form { + flex-grow: 1; + } + } + } +</style> diff --git a/shared/components/src/components/SearchSuggestions/SearchSuggestions.svelte b/shared/components/src/components/SearchSuggestions/SearchSuggestions.svelte new file mode 100644 index 0000000..c3140ae --- /dev/null +++ b/shared/components/src/components/SearchSuggestions/SearchSuggestions.svelte @@ -0,0 +1,331 @@ +<script lang="ts"> + import focusNode from '@amp/web-app-components/src/actions/focus-node'; + import { onMount, onDestroy } from 'svelte'; + import { createEventDispatcher } from 'svelte'; + import { SEARCH_EVENTS } from '@amp/web-app-components/src/constants'; + import type { HighlightedSearchSuggestion } from '@amp/web-app-components/src/utils/processTextSearchSuggestion'; + import TextSearchSuggestion from '@amp/web-app-components/src/components/TextSearchSuggestion/TextSearchSuggestion.svelte'; + + /** + * The list of suggestions + * @type {Array} + */ + export let suggestions: Array<any> = []; + + /** + * The current focused suggestion index + * @type {number} + */ + export let focusedSuggestionIndex: number | null = null; + + /** + * The translate fn to be used to handle localization + * @type {function} + */ + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + + const dispatch = createEventDispatcher(); + + let searchSuggestionsScrimElement: HTMLDivElement; + let domPortalElement: HTMLDivElement; + + onMount(() => { + domPortalElement = document.createElement('div'); + domPortalElement.className = 'portal'; + domPortalElement.appendChild(searchSuggestionsScrimElement); + + // All onyx based apps use `.app-container` as top level of app elements. + // For z-indexing to be correct we need to create portal at same level as app. + const appTarget = + document.querySelector('.app-container') ?? document.body; + appTarget.appendChild(domPortalElement); + + // this is a cleanup task, same as 'onDestroy', + // if for whatever reason the onMount becomes async + // move this into an `onDestroy` + return () => { + if (domPortalElement) { + appTarget.removeChild(domPortalElement); + } + }; + }); + + function handleSuggestionClicked(suggestion: HighlightedSearchSuggestion) { + dispatch(SEARCH_EVENTS.SUGGESTION_CLICKED, { suggestion }); + } + + function handleSuggestionKeyUp( + suggestion: HighlightedSearchSuggestion, + event: KeyboardEvent, + ) { + switch (event.key) { + case 'Enter': + case ' ': // Spacebar + dispatch(SEARCH_EVENTS.SUGGESTION_CLICKED, { suggestion }); + break; + } + } + + function handleSuggestionFocused( + suggestion: HighlightedSearchSuggestion, + index: number, + ) { + dispatch(SEARCH_EVENTS.SUGGESTION_FOCUSED, { suggestion, index }); + } +</script> + +<ul + aria-label={translateFn('AMP.Shared.SearchInput.Suggestions')} + role="listbox" + data-testid="search-suggestions" + id="search-suggestions" + class="search-suggestions" +> + {#each suggestions as suggestion, index} + <!-- + Events using `self` modifier have this in order to filter out + events that are directed to a child (i.e. pressing `Enter` or + focusing on a context menu button). + --> + <li + class="search-hint" + class:search-hint--text={suggestion.kind === 'text'} + class:search-hint--lockup={suggestion.kind !== 'text'} + use:focusNode={focusedSuggestionIndex} + data-index={index} + data-testid={`suggestion-index-${index}`} + role="option" + tabindex="0" + aria-selected={focusedSuggestionIndex === index ? true : undefined} + id={`search-suggestion-${index}`} + on:click={() => handleSuggestionClicked(suggestion)} + on:keyup|self={(e) => handleSuggestionKeyUp(suggestion, e)} + on:focusin|self={() => handleSuggestionFocused(suggestion, index)} + > + {#if $$slots['suggestion']} + <slot name="suggestion" {suggestion} /> + {:else} + <TextSearchSuggestion {suggestion} /> + {/if} + </li> + {/each} +</ul> + +<div + class="search-suggestions-scrim" + data-testid="search-suggestions-scrim" + bind:this={searchSuggestionsScrimElement} +/> + +<style lang="scss"> + @use 'amp/stylekit/core/mixins/browser-targets' as *; + @use 'amp/stylekit/core/mixins/materials' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/app/core/mixins/absolute-center' as *; + + $search-hints-vertical-padding: 6px; + + @mixin search-hint-border { + &::before { + top: 0; + inset-inline-start: var(--searchHintBorderStart, 6px); + inset-inline-end: var(--searchHintBorderEnd, 6px); + position: absolute; + content: ''; + border-top: var(--keyline-border-style); + + @content; + } + } + + .search-suggestions { + margin-top: 12px; + + @media (--sidebar-visible) { + padding: $search-hints-vertical-padding 0; + margin-top: 0; + width: 302px; + // Calculate the distance from the top of the window so we can get the height right to allow it to scroll within the page + // with exactly 25px (our $-web-navigation-inline-padding sizing). + // 3px is the distance difference in the spec from the calculations we have here. + max-height: calc( + 100vh - #{$global-player-bar-height} - #{$web-search-input-height} - + #{$web-navigation-inline-padding} + 3px + ); + position: absolute; + top: 36px; + border-radius: 9px; + overflow-x: hidden; + overflow-y: auto; + border: $dialog-border; + box-shadow: $dialog-inset-shadow, $dialog-shadow; + text-align: start; + z-index: calc(var(--z-contextual-menus) + 2); + + @include system-standard-thick-material; + + li:not(.search-hint--text) { + &:focus-visible { + outline: none; // Hide default focus ring as background color serves as focus state + } + } + } + } + + @include target-safari { + // Safari Safari 14.1 fails to render contents of `search-hint--text`, with `background-filter`, when content does not overflow + // `search-hint--text` container. `1px` of extra negative `margin-bottom` and `padding-bottom` on last element, helps trigger overflow. + // This issue is not reproducible in Safari 14.2. + li:last-child { + margin-bottom: -$search-hints-vertical-padding - 1; + padding-bottom: $search-hints-vertical-padding + 1; + } + } + + .search-hint { + position: relative; + border-radius: var( + --global-border-radius-xsmall, + #{$global-border-radius-xsmall} + ); + z-index: var(--z-default); + + // Hover/focus styles for desktop only + @media (--sidebar-visible) { + &:hover, + &:focus-visible, + &:focus-within { + // Ensure favorited badge is visible when focused + --favoriteBadgeColor: white; + background-color: var(--keyColor); + outline: none; // Hide default focus ring as background color serves as focus state + + :global(svg) { + fill: white; + } + + // Applies to all text in child <span> tags -- works for text and lockup suggestions + :global(span) { + color: white; + } + } + } + } + + .search-hint--lockup { + @include search-hint-border; + + @media (--range-sidebar-hidden-down) { + --searchHintBorderStart: var( + --searchHintBorderStartOverride, + 68px + ); // Border starts after artwork. This is overridden using `:has` in child + --searchHintBorderEnd: calc(-1 * var(--bodyGutter)); + + // Show full divider before first child, and between text and lockup hints + &:first-child, + .search-hint--text + & { + --searchHintBorderStart: 0; + } + } + + @media (--sidebar-visible) { + $top-search-list-gutter: 6px; + width: calc(100% - #{$top-search-list-gutter * 2}); + margin-inline-start: $top-search-list-gutter; + margin-inline-end: $top-search-list-gutter; + + // Hide border on currently hovered/focused item + &:hover, + &:focus-visible, + &:focus-within { + &::before { + border-color: transparent; + } + } + + // Hide border on item directly after currently hovered/focused item + &:hover + &, + &:focus-visible + &, + &:focus-within + & { + &::before { + border-color: transparent; + } + } + } + } + + .search-hint--text { + align-items: center; + display: grid; + grid-template-columns: 20px auto; + + // Add borders between text search hints on sidebar hidden + @media (--range-sidebar-hidden-down) { + --searchHintBorderStart: 26px; // Border starts after search icon + --searchHintBorderEnd: calc(-1 * var(--bodyGutter)); + padding-block: 15px; + + @include search-hint-border; + + &:first-child { + --searchHintBorderStart: 0; + } + } + + @media (--sidebar-visible) { + grid-template-columns: 16px auto; + margin: 0 6px; + padding: 4px; + font: var(--body); + + &:focus-within { + background-color: var(--keyColor); + outline: none; // Hide default focus ring as background color serves as focus state + + :global(.search-suggestion-svg) { + fill: white; + } + + :global(span) { + color: white; + } + } + } + + :global(.search-suggestion-svg) { + justify-self: center; + align-self: start; + width: 16px; + height: 16px; + transform: translateY(4px); + + @media (--sidebar-visible) { + width: 11px; + height: 11px; + transform: translateY(2.5px); + } + } + + + .search-hint--lockup { + @media (--sidebar-visible) { + margin-top: 6px; // Add small margin between '.search-hint--text' and '.search-hint--lockup' on larger viewports per spec + } + } + } + + .search-suggestions-scrim { + @include absolute-center; + + @media (--range-sidebar-hidden-down) { + display: none; + } + + @media (--sidebar-visible) { + z-index: calc(var(--z-default) + 1); + } + } +</style> diff --git a/shared/components/src/components/Shelf/Nav.svelte b/shared/components/src/components/Shelf/Nav.svelte new file mode 100644 index 0000000..1fe3933 --- /dev/null +++ b/shared/components/src/components/Shelf/Nav.svelte @@ -0,0 +1,199 @@ +<script lang="ts"> + import type { ArrowOffset } from '@amp/web-app-components/src/components/Shelf/types'; + import ChevronCompactLeft from '@amp/web-app-components/assets/shelf/chevron-compact-left.svg'; + import { createEventDispatcher } from 'svelte'; + + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + export let headerHeight: number; + export let arrowOffset: ArrowOffset; + export let hasNextPage: boolean; + export let hasPreviousPage: boolean; + export let isRTL: boolean; + + $: hasNavArrows = hasPreviousPage || hasNextPage; + + // Adjusting arrows to center on content. + // This is a fallback for browsers that don't support CSS anchor positioning. + $: addSpaceForHeader = (() => { + let offsetStyle = '0px'; + + // Custom adjustment provided by user + if (arrowOffset && arrowOffset.length) { + arrowOffset.forEach(({ direction, offset }) => { + if (direction == 'top') { + offsetStyle = ` + ${offset}px; + `; + } else { + offsetStyle = ` + calc(${offset}px * -1); + `; + } + }); + } + // Adjust for header + if (headerHeight) { + // adjust nav height to account for header + offsetStyle = ` + ${headerHeight}px; + `; + } + + return offsetStyle; + })(); + + const NAV = { + PREVIOUS: 'previous', + NEXT: 'next', + } as const; + + const dispatch = createEventDispatcher(); + const handleNextPage = () => dispatch(NAV.NEXT); + const handlePreviousPage = () => dispatch(NAV.PREVIOUS); + + $: NEXT_ARROW_PROPS = { + disabled: !hasNextPage, + 'aria-label': translateFn('AMP.Shared.NextPage'), + }; + + $: PREV_ARROW_PROPS = { + disabled: !hasPreviousPage, + 'aria-label': translateFn('AMP.Shared.PreviousPage'), + }; + + $: rightArrowProps = isRTL ? PREV_ARROW_PROPS : NEXT_ARROW_PROPS; + $: rightClick = isRTL ? handlePreviousPage : handleNextPage; + + $: leftArrowProps = isRTL ? NEXT_ARROW_PROPS : PREV_ARROW_PROPS; + $: leftClick = isRTL ? handleNextPage : handlePreviousPage; +</script> + +{#if hasNavArrows} + <button + {...leftArrowProps} + type="button" + class="shelf-grid-nav__arrow shelf-grid-nav__arrow--left" + data-testId="shelf-button-left" + on:click={leftClick} + style="--offset: {addSpaceForHeader};" + > + <ChevronCompactLeft /> + </button> + <slot name="shelf-content" /> + <button + {...rightArrowProps} + type="button" + class="shelf-grid-nav__arrow shelf-grid-nav__arrow--right" + data-testId="shelf-button-right" + on:click={rightClick} + style="--offset: {addSpaceForHeader};" + > + <ChevronCompactLeft /> + </button> +{:else} + <slot name="shelf-content" /> +{/if} + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use './style/core.scss' as *; + + .shelf-grid-nav { + list-style: none; + margin: 0; + + ul { + list-style: none; + margin: 0; + } + } + + .shelf-grid-nav__arrow { + height: $shelf-grid-arrow-height; + width: $shelf-grid-arrow-width; + align-items: center; + border: none; + cursor: pointer; + display: flex; + justify-content: center; + overflow: hidden; + position: absolute; + top: 50%; + transition: $shelf-grid-nav-transition; + translate: 0 -50%; + border-radius: 6px; + + // Non GPU-accelerated layers must be below GPU-accelerated layers. + z-index: var(--z-default); + + // Fallback for browsers that don't support CSS anchor positioning + @supports not (top: anchor(--a center)) { + transform: translateY(calc(-50% + var(--offset))); + translate: none; + } + + // CSS Anchor Positioning to vertically center paddles with artwork + // Powerswoosh intentionally not targeted — doesn't have `shelf` class. + :global(.shelf:has(.shelf-grid__list--grid-rows-1)) & { + // Set `top` to align with center of first artwork in 1-row shelf. + // Targets anchor in `Shelf.svelte`. + top: anchor(--shelf-first-artwork center, 50%); + } + + :global(svg) { + width: 8.5px; + height: 30.5px; + fill: var(--systemSecondary); + } + + &:hover, + &:focus-visible { + text-decoration: none; + background: var(--systemQuinary); + + @media (prefers-color-scheme: dark) { + background: var(--systemQuaternary); + } + } + + &:active { + background: var(--systemQuaternary); + + @media (prefers-color-scheme: dark) { + background: var(--systemTertiary); + } + + :global(svg) { + fill: var(--systemPrimary); + } + } + + &:disabled { + cursor: default; + opacity: 0; + } + + // Paddles not used in xsmall viewport + @media (--range-xsmall-down) { + display: none; + } + } + + .shelf-grid-nav__arrow--right { + right: $shelf-grid-arrow-position; + scale: -1 1; // Flip icon horizontally + } + + .shelf-grid-nav__arrow--left { + left: $shelf-grid-arrow-position; + } + + @media (--range-xsmall-down) { + .shelf-grid-nav { + display: none; + } + } +</style> diff --git a/shared/components/src/components/Shelf/Shelf.svelte b/shared/components/src/components/Shelf/Shelf.svelte new file mode 100644 index 0000000..92527bb --- /dev/null +++ b/shared/components/src/components/Shelf/Shelf.svelte @@ -0,0 +1,535 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import Nav from '@amp/web-app-components/src/components/Shelf/Nav.svelte'; + import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars'; + import { checkItemPositionInShelf } from '@amp/web-app-components/src/components/Shelf/utils/observerCallback'; + import { ShelfWindow } from '@amp/web-app-components/src/components/Shelf/utils/shelf-window'; + import { throttle } from '@amp/web-app-components/src/utils/throttle'; + import { GRID_COLUMN_GAP_DEFAULT } from '@amp/web-app-components/src/components/Shelf/constants'; + import scrollByPolyfill from '@amp/web-app-components/src/utils/scrollByPolyfill'; + import { TEXT_DIRECTION } from '@amp/web-app-components/src/constants'; + import type { + GridType, + ArrowOffset, + AspectRatioOverrideConfig, + } from '@amp/web-app-components/src/components/Shelf/types'; + import { observe } from '@amp/web-app-components/src/components/Shelf/actions/observe'; + import ShelfItem from '@amp/web-app-components/src/components/Shelf/ShelfItem.svelte'; + import { createVisibleIndexStore } from '@amp/web-app-components/src/components/Shelf/store/visibleStore'; + import { getMaxVisibleItems } from '@amp/web-app-components/src/components/Shelf/utils/getMaxVisibleItems'; + import { createShelfAspectRatioContext } from '@amp/web-app-components/src/utils/shelfAspectRatio'; + import type { Readable } from 'svelte/store'; + + type T = $$Generic; + + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + // eslint-disable-next-line no-undef-init + export let id: string | undefined = undefined; + export let items: T[]; + export let gridType: GridType; + export let gridRows = 1; + export let arrowOffset: ArrowOffset | null = null; + // TODO: rdar://112908912 (Update `alignItems` prop in Shelf component and config to better match its actual function) + export let alignItems = false; + export let stackXSItems = false; + export let overflowBleedBottom: string = null; + export let aspectRatioOverride: AspectRatioOverrideConfig = null; + export let getItemIdentifier: + | ((item: unknown, index?: number) => string) + | null = null; + export let pageScrollMultiplier: number = null; + + /** + * On shelf scroll this handler returns the first and last indexes + * of the items currently visible in the shelf viewport. + */ + export let onIntersectionUpdate: ( + itemIndexsInViewport: [number, number], + ) => void | null = null; + /** + * Determines the first index in the items[] that should be visible on load. + * Defaults to the start of the items[]. + */ + export let firstItemIndex: number = 0; + + // Exporting a function to scroll to a specific page number + export function scrollToPage(pageNumber: number): void { + pageScroll(pageMultiplier * pageNumber); + } + + // This makes the let:item of type T + function cast(x: T): T { + return x as T; + } + + const shelfItemIdentifier = ( + item: unknown, + index: number, + ): unknown | string => { + let id: string; + if (typeof getItemIdentifier === 'function') { + id = getItemIdentifier(item, index); + if (typeof id !== 'string') { + // TODO: rdar://92459555 (Shared Components: integrate app logger in to shared components) + console.debug( + 'Could not get unique id, falling back to default', + item, + ); + } + } else if (isObjectWithId(item)) { + id = item.id; + } + return id || item; + }; + + interface WithID { + id: string; + } + function isObjectWithId(o: unknown): o is WithID { + return typeof o === 'object' && 'id' in o; + } + + // used to center arrows + let headerHeight = 0; + + // Corresponds to `$global-container-shadow-offset` in `_globavars.scss` + const STANDARD_LOCKUP_SHADOW_OFFSET = 15; + + let shelfAspectRatioStore: Readable<string> | null = null; + if (aspectRatioOverride !== null) { + const { shelfAspectRatio } = + createShelfAspectRatioContext(aspectRatioOverride); + shelfAspectRatioStore = shelfAspectRatio; + } + + $: style = (() => { + // TODO: possibly move this to app level rdar://74522896 + let customStyles = ` + ${getGridVars(gridType)} + --grid-type: ${gridType}; + --grid-rows: ${gridRows}; + --standard-lockup-shadow-offset: ${STANDARD_LOCKUP_SHADOW_OFFSET}px; + ${ + aspectRatioOverride !== null && $shelfAspectRatioStore !== null + ? `--shelf-aspect-ratio: ${$shelfAspectRatioStore};` + : '' + } + `; + + if (overflowBleedBottom) { + customStyles += `--overflowBleedBottom: ${overflowBleedBottom};`; + } + + return customStyles; + })(); + + let scrollableContainer: HTMLUListElement = null; + + let hasPreviousPage = false; + let hasNextPage = true; + let shelfBodyBoundingRect: HTMLDivElement = null; + + let observer: IntersectionObserver = null; + let viewport: [number, number] | null = null; + $: isRTL = false; + + const visibleStore = createVisibleIndexStore(); + const initalVisibleGridItems = + getMaxVisibleItems(gridType) * (gridRows || 1); + visibleStore.updateEndIndex(initalVisibleGridItems); + + const createObserver = (shelfBody: HTMLElement) => { + const options = { + root: shelfBody, + rootMargin: '0px', + threshold: 0.5, + }; + + const shelfWindow = new ShelfWindow(); + const callback = (entries: IntersectionObserverEntry[]) => { + const LAST_ITEM = items.length - 1; + entries.forEach((entry) => { + const item = entry.target as HTMLUListElement; + const currentIndex = parseInt(item.dataset.index, 10); + + // to prevent user seeing items loading + // load a few items off screen + const EXTRA_ITEMS = 2 * gridRows || 2; + const [isFirstItemAndInView, isLastItemAndInView] = + checkItemPositionInShelf(entry, LAST_ITEM); + if (entry.isIntersecting) { + shelfWindow.enterValue(currentIndex); + + const nextIndex = currentIndex + 1; + if (nextIndex >= $visibleStore.endIndex) { + const lastIndex = currentIndex + EXTRA_ITEMS; + visibleStore.updateEndIndex(lastIndex); + } + setShelfItemInteractivity(entry.target, true); + } else { + shelfWindow.exitValue(currentIndex); + setShelfItemInteractivity(entry.target, false); + } + + if (isFirstItemAndInView !== null) { + hasPreviousPage = !isFirstItemAndInView; + } + + if (isLastItemAndInView !== null) { + hasNextPage = !isLastItemAndInView; + } + }); + + viewport = shelfWindow.getViewport(); + + if (viewport && onIntersectionUpdate) { + onIntersectionUpdate(viewport); + } + }; + return new IntersectionObserver(callback, options); + }; + + onMount(() => { + scrollByPolyfill(); + // rdar://81757000 (TLF: Make storefront / language updates happen in-place with JS instead of hard-refreshes) + isRTL = document.dir === TEXT_DIRECTION.RTL; + observer = createObserver(shelfBodyBoundingRect); + if (firstItemIndex !== 0) { + scrollToIndex(firstItemIndex); + } + + return () => { + observer.disconnect(); + }; + }); + + export function scrollToIndex(index: number) { + const shelfItems = scrollableContainer.getElementsByClassName( + 'shelf-grid__list-item', + ); + if (!shelfItems) { + return; + } + const firstItem = shelfItems[0] as HTMLDivElement; + const itemWidth = firstItem.getBoundingClientRect().width; + + let scrollAmount: number; + if (index === 0) { + scrollAmount = 0; + } else { + scrollAmount = + (itemWidth + + GRID_COLUMN_GAP_DEFAULT - + STANDARD_LOCKUP_SHADOW_OFFSET * 2) * + index; + } + + let offset = isRTL ? -scrollAmount : scrollAmount; + scrollableContainer.scrollTo({ left: offset, behavior: 'instant' }); + } + + const pageScroll = (pageCount = 1) => { + const containerWidth = + scrollableContainer.getBoundingClientRect().width; + const scrollAmount = + (containerWidth + + GRID_COLUMN_GAP_DEFAULT - + STANDARD_LOCKUP_SHADOW_OFFSET * 2) * + pageCount; + scrollableContainer.scrollBy(scrollAmount, 0); + }; + const THROTTLE_LIMIT = 300; + + const pageMultiplierNumber = pageScrollMultiplier || 1; + $: pageMultiplier = isRTL ? -pageMultiplierNumber : pageMultiplierNumber; + $: handleNextPage = throttle( + pageScroll.bind(null, pageMultiplier), + THROTTLE_LIMIT, + ); + $: handlePreviousPage = throttle( + pageScroll.bind(null, -pageMultiplier), + THROTTLE_LIMIT, + ); + + let firstKnownItem: WithID; + let initialScroll = 0; + function restoreScroll(node: HTMLElement, items: T[]) { + if (!isObjectWithId(items[0])) { + return {}; + } + firstKnownItem = items[0]; + return { + update(items: T[]) { + if ( + isObjectWithId(items[0]) && + items[0].id !== firstKnownItem.id && + initialScroll === 0 && + node.scrollLeft > 0 + ) { + node.scrollLeft = 0; + } + }, + }; + } + + function trackScrollPosition(e: UIEvent) { + initialScroll = (e.target as HTMLElement).scrollLeft; + } + + function setShelfItemInteractivity( + shelfItemElement: Element, + isShelfItemVisible: boolean, + ) { + const interactiveContent: NodeListOf< + HTMLAnchorElement | HTMLButtonElement + > = shelfItemElement.querySelectorAll('a, button'); + interactiveContent.forEach((interactiveElement) => { + if (interactiveElement.nodeName === 'A') { + if (isShelfItemVisible) { + interactiveElement.removeAttribute('tabindex'); + } else { + interactiveElement.setAttribute('tabindex', '-1'); + } + } else { + // if this is a <button> + if (isShelfItemVisible) { + interactiveElement.removeAttribute('disabled'); + } else { + interactiveElement.setAttribute('disabled', 'true'); + } + } + }); + } +</script> + +<section + {id} + data-testid="shelf-component" + class="shelf-grid shelf-grid--onhover" + {style} +> + {#if $$slots.header} + <div class="shelf-grid__header" bind:offsetHeight={headerHeight}> + <slot name="header" /> + </div> + {/if} + <div + class="shelf-grid__body" + data-testid="shelf-body" + bind:this={shelfBodyBoundingRect} + > + <!-- + Fix for rdar://101154977 (AX: JMOW: Play button in Album lockup is not announced) + + Firefox adds scrollable elements to the tab order, so we need to + remove the grid list from the tab order with `tabindex="-1"` so + item announcement works as expected with NVDA. + + Since it has a tabindex set, we also need to prevent the mouse from + being able to focus the element on mousedown. + --> + <!-- TODO: rdar://97308317 (Investigate svelte AX warnings in shared components) --> + <!-- + In Safari, list semantics are removed from the AX tree when + CSS property list-style-type: none is used (this does not include nav elements). + Including role="list" on ul elements will re-add list semantics. + See https://bugs.webkit.org/show_bug.cgi?id=170179 + --> + <Nav + on:next={handleNextPage} + on:previous={handlePreviousPage} + {headerHeight} + {translateFn} + {arrowOffset} + {hasNextPage} + {hasPreviousPage} + {isRTL} + > + <ul + slot="shelf-content" + class={`shelf-grid__list shelf-grid__list--grid-type-${gridType} shelf-grid__list--grid-rows-${gridRows}`} + class:shelf-grid__list--align-items-end={alignItems} + class:shelf-grid__list--stack-xs-items={stackXSItems} + role="list" + tabindex="-1" + data-testid="shelf-item-list" + on:scroll={trackScrollPosition} + bind:this={scrollableContainer} + use:restoreScroll={items} + > + <!-- + TODO: rdar://77578080 + (Shared Components: Create a keyed each loop shelf and non-keyed shelf) + --> + {#each items as item, index (shelfItemIdentifier(item, index))} + {@const isItemInteractable = + index >= viewport?.[0] && index <= viewport?.[1]} + <ShelfItem {index} {visibleStore} let:isRendered> + <!-- TODO: rdar://97308317 (Investigate svelte AX warnings in shared components) --> + <li + class="shelf-grid__list-item" + class:placeholder={!isRendered} + class:shelf-grid__list-item--stack-xs-items={stackXSItems} + data-index={index} + aria-hidden={isItemInteractable ? 'false' : 'true'} + use:observe={observer} + > + {#if isRendered} + <div + use:setShelfItemInteractivity={isItemInteractable} + > + <slot + name="item" + item={cast(item)} + {index} + numberOfItems={items.length} + /> + </div> + {/if} + </li> + </ShelfItem> + {/each} + </ul> + </Nav> + </div> +</section> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/selectors' as *; + @use 'amp/stylekit/core/viewports' as *; + @use 'amp/stylekit/core/mixins/overflow-bleed' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use './style/core.scss' as *; + @use './style/base.scss' as *; + + @mixin shelf-grid-list-styles($viewport: null) { + $grid-cols: var(--grid-#{$viewport}); + $grid-offset: calc( + (#{$grid-cols} - 1) * var(--grid-column-gap-#{$viewport}) + ); + grid-auto-columns: var( + --grid-max-content-#{$viewport}, + calc((100% - #{$grid-offset}) / #{$grid-cols}) + ); + grid-template-rows: repeat(var(--grid-rows), max-content); + column-gap: var(--grid-column-gap-#{$viewport}); + row-gap: var(--grid-row-gap-#{$viewport}); + } + + .shelf-grid__list { + // Standard lockups, of different heights, should align to titles under artwork + align-items: stretch; + + @include shelf-grid-list-styles(xsmall); + + @each $viewport in ('small', 'medium', 'large', 'xlarge') { + @media (--range-#{$viewport}-only) { + @include shelf-grid-list-styles($viewport); + } + + // Reduce column count by 1 in `medium` and `large` viewports when drawer is open + @if $viewport == 'medium' or $viewport == 'large' { + @include feature-detect(is-drawer-open) { + @media (--range-#{$viewport}-only) { + // No adjustments on Grid Types `A` and `music-radio`, for parity with DMA + &:not( + .shelf-grid__list--grid-type-A, + .shelf-grid__list--grid-type-music-radio, + .shelf-grid__list--grid-type-H + ) { + // Subtract 1 column when drawer is open + $grid-cols: calc(var(--grid-#{$viewport}) - 1); + $grid-offset: calc( + (#{$grid-cols} - 1) * + var(--grid-column-gap-#{$viewport}) + ); + grid-auto-columns: var( + --grid-max-content-#{$viewport}, + calc((100% - #{$grid-offset}) / #{$grid-cols}) + ); + } + + &.shelf-grid__list--grid-type-H { + // Subtract 2 columns on grid-type "H" only + $grid-cols: calc(var(--grid-#{$viewport}) - 2); + $grid-offset: calc( + (#{$grid-cols} - 2) * + var(--grid-column-gap-#{$viewport}) + ); + grid-auto-columns: var( + --grid-max-content-#{$viewport}, + calc((100% - #{$grid-offset}) / #{$grid-cols}) + ); + } + } + } + } + } + + @media (--small) { + :first-child { + // Set anchor for shelf chevron alignment + // Use `noShelfChevronAnchor={true}` to activate `artwork-component--no-anchor` + // class and disable chevron anchoring on an `<Artwork>` component. That will help isolate + // the true anchor when there are multiple `<Artworks>`s are in a single shelf lockup. + :global(.artwork-component:not(.artwork-component--no-anchor)) { + anchor-name: --shelf-first-artwork; + } + } + } + } + + .shelf-grid--onhover { + // stylelint-disable-next-line selector-pseudo-class-no-unknown + :global(.shelf-grid-nav__arrow) { + opacity: 0; + will-change: opacity; + transition: $shelf-grid-nav-transition; + + &:focus { + opacity: 1; + } + } + + &:hover, + &:focus-within { + // stylelint-disable-next-line selector-pseudo-class-no-unknown + :global(.shelf-grid-nav__arrow:not([disabled])) { + opacity: 1; + } + } + } + + // TODO: rdar://112908912 (Update `alignItems` prop in Shelf component and config to better match its actual function) + .shelf-grid__list--align-items-end { + --override-shelf-overflow-bleed-bottom: 35px; + padding-top: 0; + } + + // TODO: rdar://88487875 (Revisit accessibility for shelf) + // allows for accurate count for VO + // .placeholder::before { + // content: '•'; + // opacity: 0; + // } + + // Stack Music Radio shelf lockups, for `xs-1` viewport only. + .shelf-grid__list--stack-xs-items { + --override-shelf-overflow-bleed-bottom: 35px; + align-items: stretch; + + @media (--range-grid-layout-xs-1-down) { + display: block; + // Add `bodyGutter` back that is intentionally removed for peeking XS shelves. + padding-inline-end: var(--bodyGutter); + + :not(:first-child) { + margin-top: $spacerC; + } + } + } +</style> diff --git a/shared/components/src/components/Shelf/ShelfItem.svelte b/shared/components/src/components/Shelf/ShelfItem.svelte new file mode 100644 index 0000000..f164421 --- /dev/null +++ b/shared/components/src/components/Shelf/ShelfItem.svelte @@ -0,0 +1,60 @@ +<script lang="ts"> + import { getRafQueue } from '@amp/web-app-components/src/utils/rafQueue'; + import { onDestroy } from 'svelte'; + import { get, type Readable } from 'svelte/store'; + import type { VisibleIndexData } from '@amp/web-app-components/src/components/Shelf/store/visibleStore'; + + export let index: number; + export let visibleStore: Readable<VisibleIndexData>; + + const rafQueue = getRafQueue(); + const isBetween = (start: number, end: number, value: number) => { + return value >= start && value <= end; + }; + // get value but dont subscribe to it. + let { startIndex, endIndex } = get(visibleStore); + $: isRendered = isBetween(startIndex, endIndex, index); + $: isSubscribed = true; + + // Elements should only be subscribed + // to the store if they are not rendered. + const unsubscribe = visibleStore.subscribe((store) => { + const { startIndex, endIndex } = store; + const currentIsRendered = isBetween(startIndex, endIndex, index); + // Manually handling subscription to + // update DOM using RAF in browser for smoother scrolling + if (currentIsRendered && !isRendered) { + rafQueue.add(() => { + isRendered = currentIsRendered; + }); + } + }); + + /** + * Unsubscribe to the store only if `isSubscribed` is true + * + * This helps ensure that we do not accidentally call `unsubscribe` twice, + * which can cause errors in Svelte. One way that can happen is by unsubscribing + * both using `onDestory` and with the callback added to the `rafQueue` + * + * See https://github.com/sveltejs/svelte/issues/4765#issuecomment-1379243063 + */ + function unsubscribeIfNeeded() { + if (isSubscribed) { + unsubscribe(); + isSubscribed = false; + } + } + + $: if (isSubscribed && isRendered) { + rafQueue.add(() => { + unsubscribeIfNeeded(); + }); + } + + onDestroy(() => { + unsubscribeIfNeeded(); + }); +</script> + +<slot {isRendered} /> diff --git a/shared/components/src/components/Shelf/actions/observe.ts b/shared/components/src/components/Shelf/actions/observe.ts new file mode 100644 index 0000000..afa9168 --- /dev/null +++ b/shared/components/src/components/Shelf/actions/observe.ts @@ -0,0 +1,31 @@ +import type { Action } from '@amp/web-app-components/src/types'; + +// eslint-disable-next-line import/prefer-default-export +export function observe( + node: HTMLElement, + observer: IntersectionObserver, +): Action { + let oldObserver: IntersectionObserver | undefined; + + function update(observerInstance: IntersectionObserver): void { + if (oldObserver === observerInstance || !observerInstance) { + return; + } + + if (oldObserver) { + oldObserver.unobserve(node); + } + + observerInstance.observe(node); + oldObserver = observerInstance; + } + + update(observer); + + return { + update, + destroy() { + oldObserver?.unobserve(node); + }, + }; +} diff --git a/shared/components/src/components/Shelf/constants.ts b/shared/components/src/components/Shelf/constants.ts new file mode 100644 index 0000000..4a52bda --- /dev/null +++ b/shared/components/src/components/Shelf/constants.ts @@ -0,0 +1,20 @@ +// eslint-disable-next-line import/prefer-default-export +export const GRID_TYPES = [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'EllipseA', + 'Spotlight', + '1-1-2-3', + '1-2-2-2', +] as const; + +export const GRID_COLUMN_GAP_DEFAULT = 20; +export const GRID_COLUMN_GAP_DEFAULT_XSMALL = 10; +export const GRID_ROW_GAP_DEFAULT = 24; diff --git a/shared/components/src/components/Shelf/store/visibleStore.ts b/shared/components/src/components/Shelf/store/visibleStore.ts new file mode 100644 index 0000000..09b15ec --- /dev/null +++ b/shared/components/src/components/Shelf/store/visibleStore.ts @@ -0,0 +1,33 @@ +import { writable, type Readable } from 'svelte/store'; + +export type VisibleIndexData = { + startIndex: number; + endIndex: number; +}; + +export interface VisibleStore extends Readable<VisibleIndexData> { + updateStartIndex: (num: number) => void; + updateEndIndex: (num: number) => void; +} + +/** + * Store for keeping track of items rendered in shelf. + */ +export const createVisibleIndexStore = (): VisibleStore => { + const { subscribe, update } = writable({ + startIndex: 0, + endIndex: 0, + }); + + return { + subscribe, + updateStartIndex: (startIndex: number) => + update((visibleItems) => { + return { ...visibleItems, startIndex }; + }), + updateEndIndex: (endIndex: number) => + update((visibleItems) => { + return { ...visibleItems, endIndex }; + }), + }; +}; diff --git a/shared/components/src/components/Shelf/utils/getGridVars.ts b/shared/components/src/components/Shelf/utils/getGridVars.ts new file mode 100644 index 0000000..ecfe116 --- /dev/null +++ b/shared/components/src/components/Shelf/utils/getGridVars.ts @@ -0,0 +1,98 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import type { ShelfConfigOptions } from '@amp/web-app-components/config/components/shelf'; +import { ShelfConfig } from '@amp/web-app-components/config/components/shelf'; +import { + GRID_COLUMN_GAP_DEFAULT, + GRID_COLUMN_GAP_DEFAULT_XSMALL, + GRID_ROW_GAP_DEFAULT, + // eslint-disable-next-line import/no-extraneous-dependencies +} from '@amp/web-app-components/src/components/Shelf/constants'; +import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; +import type { Sizes, Size } from '@amp/web-app-components/src/types'; + +const generateGridColSizeVars = ( + viewport: Size, + gridValues: ShelfConfigOptions['GRID_VALUES'][string], + maxContents: ShelfConfigOptions['GRID_MAX_CONTENT'][string], +): string[] => { + const value = gridValues[viewport]; + const maxContent = maxContents[viewport]; + const gridVars = []; + + if (maxContent) { + // create CSS variable for px values in grid + gridVars.push(`--grid-max-content-${viewport}: ${maxContent};`); + } else if (value) { + // create CSS variable for grid unit + gridVars.push(`--grid-${viewport}: ${value};`); + } + + return gridVars; +}; + +const generateGridGapSizeVars = ( + viewport: Size, + gridColumnGap: Partial<ShelfConfigOptions['GRID_COL_GAP'][string]>, + gridRowGap: Partial<ShelfConfigOptions['GRID_ROW_GAP'][string]>, +): string[] => { + const gridVars = []; + const defaultColGap = + viewport === 'xsmall' + ? GRID_COLUMN_GAP_DEFAULT_XSMALL + : GRID_COLUMN_GAP_DEFAULT; + + // check if gap override for certain viewport + gridVars.push( + `--grid-column-gap-${viewport}: ${ + gridColumnGap[viewport] ?? defaultColGap + }px;`, + ); + gridVars.push( + `--grid-row-gap-${viewport}: ${ + gridRowGap[viewport] ?? GRID_ROW_GAP_DEFAULT + }px;`, + ); + + return gridVars; +}; + +/** + * converts the JS configs to CSS variables. + * + * variables created: + * --grid-{viewport} - grid value to use for columns widths + * --grid-max-content-{viewport} - px value to use for column width + * --grid-column-gap-{viewport} - grid gap size // default is 20px + * */ + +// eslint-disable-next-line import/prefer-default-export +export const getGridVars = (type: GridType): string => { + const { GRID_VALUES, GRID_MAX_CONTENT, GRID_COL_GAP, GRID_ROW_GAP } = + ShelfConfig.get(); + + const gridValues = GRID_VALUES[type]; + const maxContent = GRID_MAX_CONTENT[type]; + const gridRowGap = GRID_ROW_GAP[type] || {}; + const gridColumnGap = GRID_COL_GAP[type] || {}; + const gridKeys = Object.keys(gridValues) as unknown as Sizes; + + let gridVars: string[] = []; + + gridKeys.forEach((viewport) => { + // generate variables for each viewport + const gridColumnSizeVars = generateGridColSizeVars( + viewport, + gridValues, + maxContent, + ); + const gridGapSizeVars = generateGridGapSizeVars( + viewport, + gridColumnGap, + gridRowGap, + ); + + gridVars = [...gridVars, ...gridColumnSizeVars, ...gridGapSizeVars]; + }); + + return gridVars.join(' '); +}; diff --git a/shared/components/src/components/Shelf/utils/getMaxVisibleItems.ts b/shared/components/src/components/Shelf/utils/getMaxVisibleItems.ts new file mode 100644 index 0000000..226f7ba --- /dev/null +++ b/shared/components/src/components/Shelf/utils/getMaxVisibleItems.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { ShelfConfig } from '@amp/web-app-components/config/components/shelf'; +import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; + +/** + * Find the max amount of rendered items for a grid type. + */ +// eslint-disable-next-line import/prefer-default-export +export const getMaxVisibleItems = (type: GridType): number => { + const { GRID_VALUES } = ShelfConfig.get(); + + const gridValues = GRID_VALUES[type]; + + const arrayOfgridValues = [...Object.values(gridValues)].filter( + (item) => typeof item === 'number', + ); + + return Math.max(...arrayOfgridValues); +}; diff --git a/shared/components/src/components/Shelf/utils/observerCallback.ts b/shared/components/src/components/Shelf/utils/observerCallback.ts new file mode 100644 index 0000000..17ace58 --- /dev/null +++ b/shared/components/src/components/Shelf/utils/observerCallback.ts @@ -0,0 +1,30 @@ +/** + * @name checkItemPositionInShelf + * @description determine if we need to hide/show navigation arrows. + * + * @param entry entry provided by the intersection observer + * @param lastIndex index of the last item in the list + * + * @returns first/last item values ONLY when being intersected, + * otherwise will return null. + */ + +// eslint-disable-next-line import/prefer-default-export +export const checkItemPositionInShelf = ( + entry: IntersectionObserverEntry, + lastIndex: number, +): [boolean | null, boolean | null] => { + const item = entry.target as HTMLLIElement; + const itemIndexInView = item.dataset.index; + const isItemVisible = entry.isIntersecting; + + const FIRST_INDEX = '0'; + const LAST_INDEX = `${lastIndex}`; + + const isFirstItemAndInView = + itemIndexInView === FIRST_INDEX ? isItemVisible : null; + const isLastItemAndInView = + itemIndexInView === LAST_INDEX ? isItemVisible : null; + + return [isFirstItemAndInView, isLastItemAndInView]; +}; diff --git a/shared/components/src/components/Shelf/utils/shelf-window.ts b/shared/components/src/components/Shelf/utils/shelf-window.ts new file mode 100644 index 0000000..8a0501a --- /dev/null +++ b/shared/components/src/components/Shelf/utils/shelf-window.ts @@ -0,0 +1,67 @@ +/* eslint-disable import/prefer-default-export */ + +/** + * Keeps track of the items that are + * within the viewport of a shelf. + */ +export class ShelfWindow { + /** + * List of indexes of visible shelf items. + */ + private visibleShelfEntries: Set<number> = new Set(); + + /** + * The lowest visible index in the shelf viewport. + */ + private lowestIndexInVisibleShelf: number | undefined; + + /** + * The highest visible index in the shelf viewport. + */ + private highestIndexInVisibleShelf: number | undefined; + + /** + * Adds the index that has entered the viewport to to shelf item visibility set. + * @param index item's index that has entered the viewport + */ + enterValue(index: number) { + this.visibleShelfEntries.add(index); + this.setMinAndMaxValuesOfViewport(); + } + + /** + * Removes index that has left viewport from shelf item visibility set. + * + * @param index item index that has left the viewport + */ + exitValue(index: number) { + this.visibleShelfEntries.delete(index); + this.setMinAndMaxValuesOfViewport(); + } + + /** + * Set the min and max based on indexes in shelf item visiblity set. + */ + private setMinAndMaxValuesOfViewport() { + this.lowestIndexInVisibleShelf = Math.min(...this.visibleShelfEntries); + this.highestIndexInVisibleShelf = Math.max(...this.visibleShelfEntries); + } + + /** + * Get the current visible indexes for a given shelf. + * + * @returns + * the first and last item indexes in a shelf viewport + * or null if both values are not set. + */ + getViewport(): [number, number] | null { + const firstIndex = this.lowestIndexInVisibleShelf; + const secondIndex = this.highestIndexInVisibleShelf; + + if (typeof firstIndex === 'number' && typeof secondIndex === 'number') { + return [firstIndex, secondIndex]; + } + + return null; + } +} diff --git a/shared/components/src/components/TextSearchSuggestion/TextSearchSuggestion.svelte b/shared/components/src/components/TextSearchSuggestion/TextSearchSuggestion.svelte new file mode 100644 index 0000000..37793db --- /dev/null +++ b/shared/components/src/components/TextSearchSuggestion/TextSearchSuggestion.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import SearchIcon from '@amp/web-app-components/assets/icons/search.svg'; + import type { HighlightedSearchSuggestion } from '../../utils/processTextSearchSuggestion'; + + export let suggestion: HighlightedSearchSuggestion; + $: autofillBefore = suggestion.autofillBefore; + $: highlighted = suggestion.highlighted; + $: autofillAfter = suggestion.autofillAfter; +</script> + +<SearchIcon class="search-suggestion-svg" aria-hidden="true" /> +<span class="suggestion"> + <!-- + These spans cannot be broken down onto separate lines until Svelte + supports trimming of whitespace on-demand: https://github.com/sveltejs/svelte/issues/189 + TODO: rdar://101681389 (Onxy: Remove whitespace trimming workarounds) + --> + + <!-- prettier-ignore --> + <span data-testid="suggestion-autofill-before">{autofillBefore}</span><span + class="highlighted" + data-testid="suggestion-autofill-highlighted">{highlighted}</span + ><span data-testid="suggestion-autofill-after">{autofillAfter}</span> +</span> + +<style lang="scss"> + @use 'amp/stylekit/core/mixins/line-clamp' as *; + + .suggestion { + color: var(--systemSecondary); + margin: 0 6px; + font: var(--title-2); + + @include line-clamp(var(--searchSuggestionClampedLines, 1)); + + @media (--sidebar-visible) { + font: var(--callout); + } + } + + .highlighted { + color: var(--systemPrimary); + } +</style> diff --git a/shared/components/src/components/Truncate/Truncate.svelte b/shared/components/src/components/Truncate/Truncate.svelte new file mode 100644 index 0000000..d9e859f --- /dev/null +++ b/shared/components/src/components/Truncate/Truncate.svelte @@ -0,0 +1,222 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { makeSafeTick } from '@amp/web-app-components/src/utils/makeSafeTick'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '@amp/web-app-components/src/components/Modal/ContentModal.svelte'; + import { debounce } from '@amp/web-app-components/src/utils/debounce'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import type { SvelteComponent } from 'svelte'; + import { getUniqueIdGenerator } from '@amp/web-app-components/src/utils/uniqueId'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); + + /** + * @name Truncate + * + * @description + * This implements Truncate component that used to show truncated text with modal. + * + * Design: + * https://pd-hi.apple.com/viewvc/Common/Modules/macOS/Music/-Common%20Elements/Truncation.png?revision=55587 + * + */ + + export let text: string; + export let lines: number = 4; // Indicate how many lines to truncate, default to 4 + export let title: string | null = null; + export let subtitle: string | null = null; + export let translateFn: (key: string) => string; + export let modalType: 'contentModal' | null = null; + export let typography: 'title-3' | null = null; + export let bodyTypography: 'body' | null = null; + export let isPortalModal: boolean = false; + export let expandText: boolean = false; + export let usePillVariant: boolean = false; + export let sanitizeHtmlOptions: object = { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + }; + + let modalComponent: SvelteComponent; + let truncateContent: HTMLElement; + let needsTruncation = false; + let modalTriggerElement = null; + + function detectTruncate() { + needsTruncation = + truncateContent.scrollHeight > truncateContent.clientHeight; + } + + function handleMoreBtnClick(e: Event) { + e.preventDefault(); + e.stopPropagation(); + + if (expandText) { + needsTruncation = false; + truncateContent.style.setProperty('--lines', 'unset'); + } else { + handleOpenModalClick(e); + } + } + + function handleOpenModalClick(e: Event) { + modalTriggerElement = e.target; + dispatch('openModal', e); + + if (modalComponent) { + modalComponent.showModal(); + } + } + + function handleModalClose() { + modalComponent.close(); + } + + const dialogTitleId = getUniqueIdGenerator()(); + const safeTick = makeSafeTick(); + const moreButtonText = translateFn('AMP.Shared.Truncate.More') ?? ''; + + onMount(async () => { + await safeTick(async (tick) => { + // To make sure Modal bind:this setup properly before onmount + await tick(); + detectTruncate(); + }); + }); +</script> + +<!-- Detect whether need truncated or not when window resizing --> +<svelte:window on:resize={debounce(detectTruncate, 100)} /> + +<div class="truncate-wrapper" class:pill={usePillVariant && needsTruncation}> + <p + data-testid="truncate-text" + bind:this={truncateContent} + dir="auto" + class="content" + class:with-more-button={needsTruncation} + class:title-3={typography === 'title-3'} + class:body={bodyTypography === 'body'} + style:--lines={lines ?? 4} + style:--line-height="var(--lineHeight, 16)" + style:--link-length={moreButtonText.length} + > + {@html sanitizeHtml(text, sanitizeHtmlOptions)} + </p> + {#if needsTruncation} + <button + data-testid="truncate-more-button" + class="more" + type="button" + on:click={handleMoreBtnClick} + > + {moreButtonText} + </button> + {/if} +</div> + +{#if needsTruncation && !isPortalModal} + <Modal + {modalTriggerElement} + bind:this={modalComponent} + ariaLabelledBy={dialogTitleId} + > + {#if modalType === 'contentModal'} + <ContentModal + {title} + {subtitle} + {text} + {translateFn} + {dialogTitleId} + on:close={handleModalClose} + /> + {/if} + </Modal> +{/if} + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'amp/stylekit/core/mixins/line-clamp' as *; + + .truncate-wrapper { + position: relative; + z-index: var(--z-default); + } + + .content { + white-space: pre-wrap; + font: var(--truncate-font, var(--body-tall)); + + @include line-clamp(var(--lines)); + + &.title-3 { + font: var(--title-3); + + // The next line applies if `--lineHeight` was set by a parent. + line-height: calc(var(--lineHeight) * 1px); + } + + &.body { + font: var(--body); + + // The next line applies if `--lineHeight` was set by a parent. + line-height: calc(var(--lineHeight) * 1px); + } + } + + .with-more-button { + // CSS properties to build the mask based on the "MORE" button + // --one-ch property controls character width and font size + --fade-direction: 270deg; + word-break: break-word; + position: relative; // For `More` link positioning. + // prettier-ignore + mask: linear-gradient( + 0deg, + transparent 0, + transparent calc(var(--line-height) * 1px), + #000 calc(var(--line-height) * 1px) + ), + linear-gradient( + var(--fade-direction), + transparent 0, + transparent calc((var(--link-length) * var(--one-ch, 8)) * 1px + var(--inline-mask-offset, 0px)), + #000 calc(((var(--link-length) * var(--one-ch, 8)) + (var(--line-height) * 2)) * 1px + var(--inline-mask-offset, 0px)), + ); + mask-size: initial, initial; + mask-position: right bottom; + z-index: var(--z-default); + + @include rtl { + --fade-direction: 90deg; + mask-position: left bottom; + } + } + + .more { + position: absolute; + bottom: var(--moreBottomPositionOverride, 1px); + color: var(--moreTextColorOverride, var(--systemPrimary)); + inset-inline-end: 0; + padding-inline-start: 5px; + font: var(--moreFontOverride, var(--subhead-emphasized)); + z-index: var(--z-default); + } + + .pill { + --inline-mask-offset: 12px; // accommodate pill width in text mask + + .more { + padding: 0 6px; + border-radius: 8px; + margin-inline-start: 3px; + inset-inline-end: 2px; + bottom: var(--moreBottomPositionOverride, 2px); + font: var(--subhead-emphasized); + background-color: var(--systemSecondary-onDark); + color: white; // white per spec, no vars + } + } +</style> diff --git a/shared/components/src/components/buttons/Button.svelte b/shared/components/src/components/buttons/Button.svelte new file mode 100644 index 0000000..910b612 --- /dev/null +++ b/shared/components/src/components/buttons/Button.svelte @@ -0,0 +1,324 @@ +<script lang="ts"> + // TODO: rdar://92270447 (JMOTW: Refactor ButtonAction component to use Button component) + import { createEventDispatcher, onMount } from 'svelte'; + import { makeSafeTick } from '@amp/web-app-components/src/utils/makeSafeTick'; + + const dispatch = createEventDispatcher(); + + const handleButtonClick = () => { + dispatch('buttonClick'); + }; + + // Button A, B, etc. refers to the button spec + // https://pd-hi.apple.com/viewvc/Common/Modules/macOS/Music/-Common%20Elements/Buttons.png + // alertButton and alertButtonSecondary refer to Alert Modal spec + // https://pd-hi.apple.com/viewvc/Common/Modules/macOS/-Cross%20Product/_web%20-%20Alerts.png + type ButtonType = + | 'buttonA' + | 'buttonB' + | 'buttonD' + | 'alertButton' + | 'alertButtonSecondary' + | 'pillButton' + | 'socialProfileButton' + | 'textButton' + | null; + + export let buttonStyle: string | null = null; + export let makeFocused = false; + export let ariaLabel: string | null = null; + export let type: 'button' | 'submit' = 'button'; + export let disabled = false; + export let buttonElement: HTMLButtonElement = null; + + // Need to do this to resolve TS error: + // Type 'string' is not assignable to type 'ButtonType' + $: buttonType = buttonStyle as ButtonType; + + function handleKeyUp(e: KeyboardEvent) { + if (e.key === 'Enter' || e.key === 'Escape') { + handleButtonClick(); + } + } + + const safeTick = makeSafeTick(); + + onMount(async () => { + await safeTick(async (tick) => { + await tick(); + if (makeFocused) { + buttonElement.focus(); + } + }); + }); +</script> + +<div + class="button" + class:primary={buttonType === 'buttonA'} + class:secondary={buttonType === 'buttonB'} + class:tertiary={buttonType === 'buttonD'} + class:alert={buttonType && buttonType.startsWith('alertButton')} + class:alert-secondary={buttonType === 'alertButtonSecondary'} + class:pill={buttonType === 'pillButton'} + class:button--text-button={buttonType === 'textButton'} + class:socialProfileButton={buttonType === 'socialProfileButton'} + data-testid="button-base-wrapper" +> + <button + on:click={handleButtonClick} + data-testid="button-base" + aria-label={ariaLabel} + bind:this={buttonElement} + on:keyup={handleKeyUp} + class:link={buttonType === 'textButton'} + {type} + {disabled} + > + {#if $$slots['icon-before']} + <div class="button__icon button__icon--before"> + <slot name="icon-before" /> + </div> + {/if} + <slot /> + {#if $$slots['icon-after']} + <div class="button__icon button__icon--after"> + <slot name="icon-after" /> + </div> + {/if} + </button> +</div> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/app/core/mixins/keycolor-button-states' as *; + + // TODO: rdar://104573582 (Refactor <Button> and <ButtonAction> styles) + .button { + width: var(--buttonWrapperWidth, 100%); + + @media (--medium) { + width: var(--buttonWrapperWidth, auto); + } + + /* TODO: rdar://78161351: this is kind of messy */ + button { + width: var(--buttonWidth, 100%); + height: var(--buttonHeight, 36px); + display: var(--buttonDisplay, flex); + color: var(--buttonTextColor, white); + background-color: var( + --buttonBackgroundColor, + var(--keyColorBG, var(--systemBlue)) + ); + align-items: center; + justify-content: var(--buttonJustifyContent, center); + border-radius: var(--buttonRadius, #{$global-border-radius-xsmall}); + font: var(--buttonFont, var(--body-emphasized)); + + @media (--medium) { + width: var(--buttonWidth, auto); + min-width: 100px; + height: var(--buttonHeight, #{$action-button-size}); + } + + &[disabled] { + opacity: var(--buttonDisabledOpacity, 0.75); + background-color: var( + --buttonDisabledBGColor, + var(--systemQuinary) + ); + color: var(--buttonDisabledTextColor, var(--systemTertiary)); + cursor: default; + + @media (prefers-color-scheme: dark) { + opacity: var(--buttonDisabledOpacityDark, 1); + background-color: var( + --buttonDisabledBGColorDark, + rgba(255, 255, 255, 0.5) + ); + color: var( + --buttonDisabledTextColorDark, + var(--systemTertiary-onLight) + ); + } + } + } + + &.primary button { + color: var(--buttonTextColor, white); + background-color: var( + --buttonBackgroundColor, + var(--keyColorBG, var(--systemBlue)) + ); + padding: 0 10px; + + &:disabled { + opacity: 0.5; + } + } + + &.secondary { + width: auto; + + button { + --buttonBackgroundColor: transparent; + min-width: var(--buttonMinWidth, 108px); + color: var(--buttonTextColor, var(--keyColor)); + border: 1px solid + var(--buttonBorderColor, var(--keyColor, var(--systemBlue))); + font: var(--body-tall); + padding-inline-start: 16px; + padding-inline-end: 16px; + } + } + + // the tertiary styles are used for button type D + // currently only used in the snapshot project + &.tertiary { + width: auto; + + button { + --buttonBackgroundColor: var(--keyColorBG, var(--systemBlue)); + --buttonTextColor: white; + padding-inline-start: 22px; + padding-inline-end: 22px; + width: var(--buttonWidth, auto); + height: var(--buttonHeight, 45px); + font: var(--buttonFont, var(--body-reduced-semibold)); + + &:hover, + &:focus, + &:focus-within { + --buttonBackgroundColor: var( + --buttonBackgroundColorHover, + var(--keyColorBG, var(--systemBlue)) + ); + transition: all 100ms ease-in-out; + } + } + } + + &.alert { + // Prevent button inside modal from shrinking in wide viewport + --buttonWrapperWidth: 100%; + --buttonWidth: 100%; + --buttonHeight: 28px; + --buttonRadius: 6px; + } + + &.alert-secondary { + --buttonTextColor: var(--systemPrimary); + --buttonBackgroundColor: var(--systemQuinary); + + @media (prefers-color-scheme: dark) { + --buttonBackgroundColor: var(--systemTertiary); + } + } + + &.pill { + --buttonBackgroundColor: rgba(var(--keyColor-rgb), 0.06); + --buttonTextColor: var(--keyColor); + + button { + min-width: var(--buttonMinWidth, 90px); + width: var(--buttonWidth, auto); + height: var(--buttonHeight, 28px); + border-radius: var(--buttonBorderRadius, 16px); + padding-inline-start: var(--buttonPadding, 16px); + padding-inline-end: var(--buttonPadding, 16px); + font: var(--body-semibold-tall); + } + } + + &.socialProfileButton { + height: auto; + border-radius: 10px; + margin-top: 27px; + width: unset; /* unset inherited value from .button */ + min-width: 90px; + background-color: var(--keyColorBG); + z-index: var(--z-default); + + @include keycolor-button-states; + } + + &.socialProfileButton button { + padding-top: 9px; + padding-bottom: 9px; + color: var(--systemPrimary-onDark); + height: auto; + font: var(--title-2); + padding-inline-start: 22px; + padding-inline-end: 22px; + + :global(.web-to-native__action) { + fill: var(--systemPrimary-onDark); + } + } + } + + // Works in conjuction with `link` class in @amp-stylekit/base/typography + .button--text-button { + --buttonBackgroundColor: transparent; + --buttonTextColor: var(--keyColor); // `link` class will inherit this + --linkHoverTextDecoration: none; // `link` custom property + + button { + white-space: nowrap; + font: var(--buttonFont, var(--body)); + } + } + + .button__icon { + display: flex; + fill: var(--buttonIconFill, currentColor); + height: var(--buttonIconHeight, 1em); + width: var(--buttonIconWidth, 1em); + padding: var(--buttonIconPadding, 0); + margin-top: var(--buttonIconMarginTop, 0); + margin-bottom: var(--buttonIconMarginBottom, 0); + + &:empty, + &:has(div:empty) { + margin: 0; + } + + @media (hover: hover) { + button:hover & { + fill: var( + --buttonIconFillHover, + var(--buttonIconFill, currentColor) + ); + } + } + + @supports #{'selector(:has(:focus-visible))'} { + button:focus-visible & { + fill: var( + --buttonIconFillFocus, + var(--buttonIconFill, currentColor) + ); + } + } + + &:active { + button:active & { + fill: var( + --buttonIconFillActive, + var(--buttonIconFill, currentColor) + ); + } + } + } + + .button__icon--before { + margin-inline-end: var(--buttonIconMargin-inlineEnd, 0.25em); + margin-inline-start: var(--buttonIconMargin-inlineStart, 0); + } + + .button__icon--after { + margin-inline-start: var(--buttonIconMargin-inlineStart, 0.25em); + margin-inline-end: var(--buttonIconMargin-inlineEnd, 0); + } +</style> diff --git a/shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte b/shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte new file mode 100644 index 0000000..13c666c --- /dev/null +++ b/shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte @@ -0,0 +1,99 @@ +<script lang="ts"> + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import LocaleSwitcherModal from '@amp/web-app-components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherModal.svelte'; + import LocaleSwitcherLanguages from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherLanguages.svelte'; + import type { + Region, + Languages, + Language, + } from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types'; + import type { Locale } from '@amp/web-app-components/src/types'; + import type { SvelteComponent } from 'svelte'; + import type { StorefrontNames } from '@amp/web-app-components/src/components/banners/types'; + + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + export let locale: Locale; + export let regions: Region[]; + export let languages: Languages; + export let defaultRoute: string; + export let storefrontNameTranslations: StorefrontNames; + + $: language = locale.language; + $: storefront = locale.storefront; + + let modalTriggerElement = null; + let modalElement: SvelteComponent; + + const handleOpenModalClick = () => { + // only open modal on click if regions is not empty + if (regions.length) { + modalElement.showModal(); + } + }; + + $: otherLanguages = languages[storefront].filter( + (l: Language) => l.tag.toLowerCase() !== language.toLowerCase(), + ); + + $: storefrontName = + storefrontNameTranslations[storefront]?.[language] ?? + storefrontNameTranslations[storefront]?.['default']; + + // rdar://102181852 (CHN AM Web app is showing language selector in traditional Chinese.) + // We should not show the locale switcher or language selector when on the CN storefront + $: isCNStorefront = storefront === 'cn'; +</script> + +{#if storefrontName && !isCNStorefront} + <div + class="button-container" + class:languages-new-line={otherLanguages.length >= 6} + > + <button + on:click={handleOpenModalClick} + class="link" + data-testid="locale-switcher-button" + > + {storefrontName} + </button> + <LocaleSwitcherLanguages {translateFn} {otherLanguages} /> + </div> +{/if} + +<Modal {modalTriggerElement} bind:this={modalElement}> + <LocaleSwitcherModal + {translateFn} + {regions} + {defaultRoute} + on:close={modalElement.close} + /> +</Modal> + +<style lang="scss"> + .button-container { + --linkColor: var(--systemPrimary); + display: flex; + margin-bottom: 25px; + + &.languages-new-line { + @media (--range-small-down) { + flex-direction: column; + + button { + margin-bottom: 5px; + } + } + } + } + + button { + line-height: 1; + display: inline-flex; + margin-top: 6px; + vertical-align: middle; + white-space: nowrap; + } +</style> diff --git a/shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherLanguages.svelte b/shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherLanguages.svelte new file mode 100644 index 0000000..f7cdfad --- /dev/null +++ b/shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherLanguages.svelte @@ -0,0 +1,100 @@ +<script lang="ts"> + import type { Language } from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types'; + export let translateFn: ( + str: string, + values?: Record<string, string | number>, + ) => string; + export let otherLanguages: Language[]; + + const handleClick = (otherLanguage: string) => { + const url = new URL(window.location.href); + url.searchParams.set('l', otherLanguage); + window.location.assign(`${url.pathname}${url.search}`); + }; +</script> + +{#if otherLanguages.length > 0} + <ul class:languages-new-line={otherLanguages.length >= 6}> + {#each otherLanguages as otherLanguage} + {#if otherLanguage.tag && otherLanguage.name} + <li> + <a + on:click|preventDefault={() => + handleClick(otherLanguage.tag)} + href={`?l=${otherLanguage.tag}`} + aria-label={translateFn( + 'AMP.Shared.LocaleSwitcher.SwitchLanguage', + { language: otherLanguage.name }, + )} + data-testid={`other-language-${otherLanguage.tag}`} + > + {otherLanguage.name} + </a> + </li> + {/if} + {/each} + </ul> +{/if} + +<style lang="scss"> + a { + --linkColor: var(--systemSecondary); + white-space: nowrap; + padding-inline-end: 10px; + } + + ul { + display: flex; + flex-wrap: wrap; + padding-inline-start: 10px; + + &.languages-new-line { + @media (--range-small-down) { + padding-inline-start: 0; + + li { + &:first-of-type { + a { + padding-inline-start: 0; + } + + &::before { + content: ''; + height: 100%; + border-inline-start: none; + } + } + } + } + } + + li { + margin-top: 6px; + display: inline-flex; + line-height: 1; + vertical-align: middle; + + &:first-of-type { + a { + padding-inline-start: 10px; + } + + &::before { + content: ''; + height: 100%; + border-inline-start: 1px solid var(--systemQuaternary); + } + } + + &::after { + border-inline-start: 1px solid var(--systemQuaternary); + content: ''; + padding-inline-end: 10px; + } + + &:last-child::after { + content: none; + } + } + } +</style> diff --git a/shared/components/src/components/helpers/ResizeDetector.svelte b/shared/components/src/components/helpers/ResizeDetector.svelte new file mode 100644 index 0000000..67b2453 --- /dev/null +++ b/shared/components/src/components/helpers/ResizeDetector.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import { throttle } from '@amp/web-app-components/src/utils/throttle'; + + const dispatch = createEventDispatcher(); + + export let resizeThrottleLimit = 100; // Limit on how often to fire resize event + export let resizeTimeoutLimit = 250; // If resize event hasn't fired in this much time, we are no longer resizing + + let isResizing: boolean = false; + let resizeTimeoutId; + + const handleResize = () => { + isResizing = true; + + if (resizeTimeoutId) { + clearInterval(resizeTimeoutId); + } + + resizeTimeoutId = setTimeout( + () => (isResizing = false), + resizeTimeoutLimit, + ); + }; + + // Dispatch event whenever isResizing updates + $: dispatch('resizeUpdate', { isResizing }); +</script> + +<svelte:window on:resize={throttle(handleResize, resizeThrottleLimit)} /> diff --git a/shared/components/src/constants.ts b/shared/components/src/constants.ts new file mode 100644 index 0000000..826257c --- /dev/null +++ b/shared/components/src/constants.ts @@ -0,0 +1,53 @@ +// eslint-disable-next-line import/prefer-default-export +export const TEXT_DIRECTION = { + LTR: 'ltr', + RTL: 'rtl', +} as const; + +// https://www.fileformat.info/info/unicode/char/200e/index.htm +// these are unicode characters in four hexadecimal digits +export const LTR_MARK = '\u200e'; +export const RTL_MARK = '\u200f'; + +export const PLAY_STATES = { + PLAY: 'play', + PAUSE: 'pause', + BUFFER: 'buffer', + PLAYING: 'playing', +} as const; + +// eslint-disable-next-line import/prefer-default-export +export const SEARCH_EVENTS = { + MAKE_SEARCH_QUERY_FROM_SUGGESTION: 'makeSearchQueryFromSuggestion', + MAKE_SEARCH_QUERY_FROM_INPUT: 'makeSearchQueryFromInput', + CLICKED_OUTSIDE_SUGGESTIONS: 'clickedOutsideSuggestions', + CLICKED_OUTSIDE: 'clickedOutside', + RESET_SEARCH_INPUT: 'resetSearchInput', + SUGGESTION_CLICKED: 'suggestionClicked', + SUGGESTION_FOCUSED: 'suggestionFocused', + SEARCH_INPUT_HAS_FOCUS: 'searchInputHasFocus', + MENU_ITEM_CLICK: 'menuItemClick', + SHOW_SEARCH_SUGGESTIONS: 'showSearchSuggestions', + CLEAR: 'clear', +} as const; + +/** + * Locations where `SearchInput` component `clear` event can be called from. + * + * @remarks + * clear event can be triggered from two different locations + * rerturn object provides a way to distinguish between + * call points. + * + */ +export enum ClearEventLocation { + Cancel = 'cancel', + Input = 'input', +} + +export enum PopoverAnchorPositioning { + Top = 'top', + Bottom = 'bottom', + Left = 'left', + Right = 'right', +} diff --git a/shared/components/src/stores/media-query.ts b/shared/components/src/stores/media-query.ts new file mode 100644 index 0000000..83cc055 --- /dev/null +++ b/shared/components/src/stores/media-query.ts @@ -0,0 +1,63 @@ +// Based on https://github.com/cibernox/svelte-media +import { readable } from 'svelte/store'; +import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; +import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions'; + +const { BREAKPOINTS } = ArtworkConfig.get(); +const mqConditions = getMediaConditions(BREAKPOINTS); + +const DEFAULT_SETTING = 'medium'; + +/** + * Filters media query results and outputs the breakpoint name with a matching media query. + * + * @param {Object} mqls media query configurations (pulled from getMediaConditions()) + * @returns {String|undefined} breakpoint string that matches current media query + */ +function calculateMediaQuery(mqls: Record<string, MediaQueryList>): string { + return Object.entries(mqls) + .filter(([_, query]) => query.matches) + .map(([name, _]) => name)[0]; +} + +/** + * This function allows to build a store that tracks which of the given media query conditions matches. + * @param initialValue The inital value for the store. It only bears importance in server side rendering + * as it will update immediately in the browser + * @param mediaQueryConditions The dictionary with the media query names and the MQ condition to match against. + * @returns Svelte.Store<string> The name of the matching media query + */ +export function buildMediaQueryStore( + initialValue: string, + mediaQueryConditions: Record<string, string> = mqConditions, +) { + return readable(initialValue, (set) => { + if ( + typeof window === 'undefined' || + typeof matchMedia === 'undefined' + ) { + set(initialValue); + return; + } + + let mqls = {}; + let updateMediaQuery = () => set(calculateMediaQuery(mqls)); + + for (const key in mediaQueryConditions) { + mqls[key] = window.matchMedia(mediaQueryConditions[key]); + // `addListener` is deprecated but should still be used for compatibility with more browsers. + mqls[key].addListener(updateMediaQuery); + } + + updateMediaQuery(); + + return function (): void { + for (let key in mqls) { + // `removeListener` is deprecated but should still be used for compatibility with more browsers. + mqls[key].removeListener(updateMediaQuery); + } + }; + }); +} + +export const mediaQueries = buildMediaQueryStore(DEFAULT_SETTING, mqConditions); diff --git a/shared/components/src/stores/navigation-folders-open.ts b/shared/components/src/stores/navigation-folders-open.ts new file mode 100644 index 0000000..b761371 --- /dev/null +++ b/shared/components/src/stores/navigation-folders-open.ts @@ -0,0 +1,21 @@ +import { type Writable, writable } from 'svelte/store'; + +type FolderState = Writable<boolean>; +const folderStates = new Map<string, FolderState>(); + +export function subscribeFolderOpenState( + id: string, + defaultState?: boolean, +): FolderState { + let stateById = folderStates.get(id); + if (!stateById) { + folderStates.set(id, writable(defaultState ?? false)); + stateById = folderStates.get(id); + } + + return stateById; +} + +export function resetFoldersOpenState() { + folderStates.clear(); +} diff --git a/shared/components/src/stores/prefers-reduced-motion.ts b/shared/components/src/stores/prefers-reduced-motion.ts new file mode 100644 index 0000000..03d9393 --- /dev/null +++ b/shared/components/src/stores/prefers-reduced-motion.ts @@ -0,0 +1,27 @@ +import { readable } from 'svelte/store'; + +const DEFAULT_SETTING = false; + +export const prefersReducedMotion = readable(DEFAULT_SETTING, (set) => { + if (typeof window === 'undefined' || typeof matchMedia === 'undefined') { + set(DEFAULT_SETTING); + return; + } + + const motionQuery = matchMedia('(prefers-reduced-motion)'); + + /* istanbul ignore next */ + const motionQueryListener = (): void => { + set(motionQuery.matches); + }; + + // `addListener` is deprecated but should still be used for compatibility with more browsers. + motionQuery.addListener(motionQueryListener); + + set(motionQuery.matches); + + return function (): void { + // `removeListener` is deprecated but should still be used for compatibility with more browsers. + motionQuery.removeListener(motionQueryListener); + }; +}); diff --git a/shared/components/src/stores/sidebar-hidden.ts b/shared/components/src/stores/sidebar-hidden.ts new file mode 100644 index 0000000..2de14d1 --- /dev/null +++ b/shared/components/src/stores/sidebar-hidden.ts @@ -0,0 +1,12 @@ +import { derived } from 'svelte/store'; +import { buildMediaQueryStore } from '@amp/web-app-components/src/stores/media-query'; + +export const sidebarHiddenQuery = buildMediaQueryStore('visible', { + hidden: '(max-width: 483px)', + visible: '(min-width: 484px)', +}); + +export const sidebarIsHidden = derived( + sidebarHiddenQuery, + ($sidebarHiddenQuery) => $sidebarHiddenQuery === 'hidden', +); 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 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 with regular spaces if removeNbsp option is enabled + if (removeNbsp) { + html = html.replace(/ /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; +} |
