init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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();

View File

@@ -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();

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
},
};
}

View File

@@ -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());
}

View File

@@ -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();
}
},
};
}

View File

@@ -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);
},
};
}

View File

@@ -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;

View File

@@ -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);
}
},
};
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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} />

View File

@@ -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}

View File

@@ -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} />

View File

@@ -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;
});
},
};
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -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 '';
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const menuIsExpanded = writable(false);
export const menuIsTransitioning = writable(false);

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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),
) || [];

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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);
},
};
}

View File

@@ -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;

View File

@@ -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 };
}),
};
};

View File

@@ -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(' ');
};

View File

@@ -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);
};

View File

@@ -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];
};

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)} />

View File

@@ -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',
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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);
};
});

View File

@@ -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',
);

View File

@@ -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;
}

View File

@@ -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('');
}

View File

@@ -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;

View File

@@ -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,
);
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
};
};

View File

@@ -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;
};
}

View File

@@ -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;
};

View File

@@ -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);
}

View File

@@ -0,0 +1,176 @@
type AllowedTags = Set<string>;
interface AllowedAttributes {
[tagName: string]: Set<string>;
}
export interface SanitizeHtmlOptions {
allowedTags?: string[];
extraAllowedTags?: string[];
keepChildrenWhenRemovingParent?: boolean;
/**
* When true, replaces all &nbsp; entities with regular spaces
* to prevent unwanted line breaks in the rendered HTML
*/
removeNbsp?: boolean;
/**
* AllowedAttributes should be an object with tag name keys and array values
* containing all of the attributes allowed for that tag:
*
* { 'p': ['class'], 'div': ['role', 'aria-hidden'] }
*
* The above allows ONLY the class attribute for <p> and ONLY the role and
* aria-hidden attributes for <div>.
*/
allowedAttributes?: {
[tagName: string]: string[];
};
}
export const DEFAULT_SAFE_TAGS: string[] = [
'strong',
'em',
'b',
'i',
'u',
'br',
];
const DEFAULT_SAFE_ATTRS = {};
/**
* Sanitizes HTML by removing all tags and attributes that aren't explicitly allowed.
*/
export function sanitizeDocument(
unsafeDocument: Document,
unsafeNode: Node | DocumentFragment,
{
allowedTags,
extraAllowedTags,
allowedAttributes = DEFAULT_SAFE_ATTRS,
keepChildrenWhenRemovingParent,
removeNbsp,
}: SanitizeHtmlOptions = {},
): string {
if (allowedTags && extraAllowedTags) {
throw new Error(
'sanitizeHtml got both allowedTags and extraAllowedTags',
);
}
const allowedTagsSet = new Set([
...(extraAllowedTags || []),
...(allowedTags || DEFAULT_SAFE_TAGS),
]);
const allowedAttributeSets = {};
for (const [tag, attributes] of Object.entries(allowedAttributes)) {
allowedAttributeSets[tag] = new Set(attributes);
}
const sanitizedContainer = unsafeDocument.createElement('div');
for (const child of [...unsafeNode.childNodes]) {
const sanitizedChildArray = sanitizeNode(
child as Element,
allowedTagsSet,
allowedAttributeSets,
keepChildrenWhenRemovingParent,
);
sanitizedChildArray.forEach((node) => {
sanitizedContainer.appendChild(node);
});
}
let html = sanitizedContainer.innerHTML;
// Replace &nbsp; with regular spaces if removeNbsp option is enabled
if (removeNbsp) {
html = html.replace(/&nbsp;/g, ' ');
}
return html;
}
function sanitizeNode(
node: Element,
allowedTags: AllowedTags,
allowedAttributes: AllowedAttributes,
keepChildrenWhenRemovingParent: boolean,
): Node[] | Element[] {
// Plain text is safe as is
// NOTE: The lowercase node (instead of Node) is intentional. Node is only
// accessible in browser. In Node.js, it depends on jsdom (which we
// avoid importing to exclude from the clientside vendor bundle).
// Instead of passing down window.Node or jsdom.Node depending on
// context, we rely on the fact that instances of Node (of which node
// will be one) will also have these constants set on them.
if (
([node.TEXT_NODE, node.CDATA_SECTION_NODE] as number[]).includes(
node.nodeType,
)
) {
return [node];
}
// Refuse anything that isn't a tag or one of the allowed tags
const tagName = (node.tagName || '').toLowerCase();
if (!allowedTags.has(tagName)) {
// when keepChildrenWhenRemovingParent is true
// we check children for valid nodes as well
if (keepChildrenWhenRemovingParent) {
return sanitizeChildren(
node,
allowedTags,
allowedAttributes,
keepChildrenWhenRemovingParent,
);
}
return [];
}
// Reconstruct node with only the allowedAttributes and sanitize its children
const sanitized = node.ownerDocument.createElement(tagName);
const currentlyAllowedAttributes = allowedAttributes[tagName] || new Set();
for (const { name, nodeValue: value } of [...node.attributes]) {
if (currentlyAllowedAttributes.has(name)) {
sanitized.setAttribute(name, value);
}
}
const children = sanitizeChildren(
node,
allowedTags,
allowedAttributes,
keepChildrenWhenRemovingParent,
);
children.forEach((child) => {
sanitized.appendChild(child);
});
return [sanitized];
}
const sanitizeChildren = (
node: Element,
allowedTags: AllowedTags,
allowedAttributes: AllowedAttributes,
tagsToConvertToText: boolean,
): Node[] => {
const children = [...node.childNodes]
.map((childNode) =>
sanitizeNode(
childNode as Element,
allowedTags,
allowedAttributes,
tagsToConvertToText,
),
)
.flat();
return children;
};

View File

@@ -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 '';
}
}

View File

@@ -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;
}

View File

@@ -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();
};

View File

@@ -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;
}

View File

@@ -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);
}
};
}

View File

@@ -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;
}