mirror of
https://github.com/rxliuli/apps.apple.com.git
synced 2025-11-09 20:00:34 +00:00
init commit
This commit is contained in:
1
shared/components/assets/icons/arrow.svg
Normal file
1
shared/components/assets/icons/arrow.svg
Normal 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"
|
||||
1
shared/components/assets/icons/chevron.svg
Normal file
1
shared/components/assets/icons/chevron.svg
Normal 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"
|
||||
1
shared/components/assets/icons/close.svg
Normal file
1
shared/components/assets/icons/close.svg
Normal 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"
|
||||
1
shared/components/assets/icons/search.svg
Normal file
1
shared/components/assets/icons/search.svg
Normal 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"
|
||||
1
shared/components/assets/icons/star-filled.svg
Normal file
1
shared/components/assets/icons/star-filled.svg
Normal 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"
|
||||
1
shared/components/assets/icons/star-hollow.svg
Normal file
1
shared/components/assets/icons/star-hollow.svg
Normal 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"
|
||||
1
shared/components/assets/shelf/chevron-compact-left.svg
Normal file
1
shared/components/assets/shelf/chevron-compact-left.svg
Normal 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"
|
||||
103
shared/components/config/components/artwork.ts
Normal file
103
shared/components/config/components/artwork.ts
Normal 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();
|
||||
116
shared/components/config/components/shelf.ts
Normal file
116
shared/components/config/components/shelf.ts
Normal 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();
|
||||
428
shared/components/node_modules/intersection-observer-admin/dist/intersection-observer-admin.es5.js
generated
vendored
Normal file
428
shared/components/node_modules/intersection-observer-admin/dist/intersection-observer-admin.es5.js
generated
vendored
Normal 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
|
||||
291
shared/components/src/actions/allow-drag.ts
Normal file
291
shared/components/src/actions/allow-drag.ts
Normal 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;
|
||||
249
shared/components/src/actions/allow-drop.ts
Normal file
249
shared/components/src/actions/allow-drop.ts
Normal 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;
|
||||
18
shared/components/src/actions/click-outside.ts
Normal file
18
shared/components/src/actions/click-outside.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
5
shared/components/src/actions/focus-node-on-mount.ts
Normal file
5
shared/components/src/actions/focus-node-on-mount.ts
Normal 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());
|
||||
}
|
||||
19
shared/components/src/actions/focus-node.ts
Normal file
19
shared/components/src/actions/focus-node.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
100
shared/components/src/actions/intersection-observer.ts
Normal file
100
shared/components/src/actions/intersection-observer.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
351
shared/components/src/actions/list-keyboard-access.ts
Normal file
351
shared/components/src/actions/list-keyboard-access.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
565
shared/components/src/components/Artwork/Artwork.svelte
Normal file
565
shared/components/src/components/Artwork/Artwork.svelte
Normal 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>
|
||||
227
shared/components/src/components/Artwork/constants.ts
Normal file
227
shared/components/src/components/Artwork/constants.ts
Normal 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;
|
||||
@@ -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} />
|
||||
@@ -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}
|
||||
@@ -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} />
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
77
shared/components/src/components/Artwork/utils/artProfile.ts
Normal file
77
shared/components/src/components/Artwork/utils/artProfile.ts
Normal 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;
|
||||
};
|
||||
64
shared/components/src/components/Artwork/utils/preconnect.ts
Normal file
64
shared/components/src/components/Artwork/utils/preconnect.ts
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
467
shared/components/src/components/Artwork/utils/srcset.ts
Normal file
467
shared/components/src/components/Artwork/utils/srcset.ts
Normal 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;
|
||||
}
|
||||
@@ -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 '';
|
||||
};
|
||||
83
shared/components/src/components/Error/ErrorPage.svelte
Normal file
83
shared/components/src/components/Error/ErrorPage.svelte
Normal 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>
|
||||
195
shared/components/src/components/Footer/Footer.svelte
Normal file
195
shared/components/src/components/Footer/Footer.svelte
Normal 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>
|
||||
238
shared/components/src/components/LineClamp/LineClamp.svelte
Normal file
238
shared/components/src/components/LineClamp/LineClamp.svelte
Normal 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>
|
||||
@@ -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>
|
||||
262
shared/components/src/components/MetaTags/MetaTags.svelte
Normal file
262
shared/components/src/components/MetaTags/MetaTags.svelte
Normal 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>
|
||||
222
shared/components/src/components/Modal/ContentModal.svelte
Normal file
222
shared/components/src/components/Modal/ContentModal.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
246
shared/components/src/components/Modal/Modal.svelte
Normal file
246
shared/components/src/components/Modal/Modal.svelte
Normal 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>
|
||||
277
shared/components/src/components/Navigation/Folder.svelte
Normal file
277
shared/components/src/components/Navigation/Folder.svelte
Normal 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>
|
||||
183
shared/components/src/components/Navigation/Item.svelte
Normal file
183
shared/components/src/components/Navigation/Item.svelte
Normal 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>
|
||||
@@ -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>
|
||||
178
shared/components/src/components/Navigation/MenuIcon.svelte
Normal file
178
shared/components/src/components/Navigation/MenuIcon.svelte
Normal 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>
|
||||
298
shared/components/src/components/Navigation/Navigation.svelte
Normal file
298
shared/components/src/components/Navigation/Navigation.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const menuIsExpanded = writable(false);
|
||||
export const menuIsTransitioning = writable(false);
|
||||
27
shared/components/src/components/Navigation/utils.ts
Normal file
27
shared/components/src/components/Navigation/utils.ts
Normal 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;
|
||||
}
|
||||
141
shared/components/src/components/Rating/Rating.svelte
Normal file
141
shared/components/src/components/Rating/Rating.svelte
Normal 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>
|
||||
10
shared/components/src/components/Rating/utils.ts
Normal file
10
shared/components/src/components/Rating/utils.ts
Normal 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),
|
||||
) || [];
|
||||
530
shared/components/src/components/SearchInput/SearchInput.svelte
Normal file
530
shared/components/src/components/SearchInput/SearchInput.svelte
Normal 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>
|
||||
@@ -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>
|
||||
199
shared/components/src/components/Shelf/Nav.svelte
Normal file
199
shared/components/src/components/Shelf/Nav.svelte
Normal 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>
|
||||
535
shared/components/src/components/Shelf/Shelf.svelte
Normal file
535
shared/components/src/components/Shelf/Shelf.svelte
Normal 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>
|
||||
60
shared/components/src/components/Shelf/ShelfItem.svelte
Normal file
60
shared/components/src/components/Shelf/ShelfItem.svelte
Normal 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} />
|
||||
31
shared/components/src/components/Shelf/actions/observe.ts
Normal file
31
shared/components/src/components/Shelf/actions/observe.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
20
shared/components/src/components/Shelf/constants.ts
Normal file
20
shared/components/src/components/Shelf/constants.ts
Normal 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;
|
||||
33
shared/components/src/components/Shelf/store/visibleStore.ts
Normal file
33
shared/components/src/components/Shelf/store/visibleStore.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
};
|
||||
98
shared/components/src/components/Shelf/utils/getGridVars.ts
Normal file
98
shared/components/src/components/Shelf/utils/getGridVars.ts
Normal 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(' ');
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
67
shared/components/src/components/Shelf/utils/shelf-window.ts
Normal file
67
shared/components/src/components/Shelf/utils/shelf-window.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
222
shared/components/src/components/Truncate/Truncate.svelte
Normal file
222
shared/components/src/components/Truncate/Truncate.svelte
Normal 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>
|
||||
324
shared/components/src/components/buttons/Button.svelte
Normal file
324
shared/components/src/components/buttons/Button.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)} />
|
||||
53
shared/components/src/constants.ts
Normal file
53
shared/components/src/constants.ts
Normal 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',
|
||||
}
|
||||
63
shared/components/src/stores/media-query.ts
Normal file
63
shared/components/src/stores/media-query.ts
Normal 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);
|
||||
21
shared/components/src/stores/navigation-folders-open.ts
Normal file
21
shared/components/src/stores/navigation-folders-open.ts
Normal 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();
|
||||
}
|
||||
27
shared/components/src/stores/prefers-reduced-motion.ts
Normal file
27
shared/components/src/stores/prefers-reduced-motion.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
12
shared/components/src/stores/sidebar-hidden.ts
Normal file
12
shared/components/src/stores/sidebar-hidden.ts
Normal 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',
|
||||
);
|
||||
71
shared/components/src/utils/cookie.ts
Normal file
71
shared/components/src/utils/cookie.ts
Normal 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;
|
||||
}
|
||||
51
shared/components/src/utils/date.ts
Normal file
51
shared/components/src/utils/date.ts
Normal 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('');
|
||||
}
|
||||
40
shared/components/src/utils/debounce.ts
Normal file
40
shared/components/src/utils/debounce.ts
Normal 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;
|
||||
117
shared/components/src/utils/getMediaConditions.ts
Normal file
117
shared/components/src/utils/getMediaConditions.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
29
shared/components/src/utils/getStorefrontRoute.ts
Normal file
29
shared/components/src/utils/getStorefrontRoute.ts
Normal 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;
|
||||
}
|
||||
25
shared/components/src/utils/getUpdatedFocusedIndex.ts
Normal file
25
shared/components/src/utils/getUpdatedFocusedIndex.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
shared/components/src/utils/internal/locale/index.ts
Normal file
17
shared/components/src/utils/internal/locale/index.ts
Normal 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;
|
||||
}
|
||||
64
shared/components/src/utils/makeSafeTick.ts
Normal file
64
shared/components/src/utils/makeSafeTick.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
26
shared/components/src/utils/memoize.ts
Normal file
26
shared/components/src/utils/memoize.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
74
shared/components/src/utils/rafQueue.ts
Normal file
74
shared/components/src/utils/rafQueue.ts
Normal 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;
|
||||
};
|
||||
26
shared/components/src/utils/sanitize-html/browser.ts
Normal file
26
shared/components/src/utils/sanitize-html/browser.ts
Normal 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);
|
||||
}
|
||||
176
shared/components/src/utils/sanitize-html/common.ts
Normal file
176
shared/components/src/utils/sanitize-html/common.ts
Normal 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 entities with regular spaces
|
||||
* to prevent unwanted line breaks in the rendered HTML
|
||||
*/
|
||||
removeNbsp?: boolean;
|
||||
|
||||
/**
|
||||
* AllowedAttributes should be an object with tag name keys and array values
|
||||
* containing all of the attributes allowed for that tag:
|
||||
*
|
||||
* { 'p': ['class'], 'div': ['role', 'aria-hidden'] }
|
||||
*
|
||||
* The above allows ONLY the class attribute for <p> and ONLY the role and
|
||||
* aria-hidden attributes for <div>.
|
||||
*/
|
||||
allowedAttributes?: {
|
||||
[tagName: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_SAFE_TAGS: string[] = [
|
||||
'strong',
|
||||
'em',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'br',
|
||||
];
|
||||
const DEFAULT_SAFE_ATTRS = {};
|
||||
|
||||
/**
|
||||
* Sanitizes HTML by removing all tags and attributes that aren't explicitly allowed.
|
||||
*/
|
||||
export function sanitizeDocument(
|
||||
unsafeDocument: Document,
|
||||
unsafeNode: Node | DocumentFragment,
|
||||
{
|
||||
allowedTags,
|
||||
extraAllowedTags,
|
||||
allowedAttributes = DEFAULT_SAFE_ATTRS,
|
||||
keepChildrenWhenRemovingParent,
|
||||
removeNbsp,
|
||||
}: SanitizeHtmlOptions = {},
|
||||
): string {
|
||||
if (allowedTags && extraAllowedTags) {
|
||||
throw new Error(
|
||||
'sanitizeHtml got both allowedTags and extraAllowedTags',
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTagsSet = new Set([
|
||||
...(extraAllowedTags || []),
|
||||
...(allowedTags || DEFAULT_SAFE_TAGS),
|
||||
]);
|
||||
|
||||
const allowedAttributeSets = {};
|
||||
for (const [tag, attributes] of Object.entries(allowedAttributes)) {
|
||||
allowedAttributeSets[tag] = new Set(attributes);
|
||||
}
|
||||
|
||||
const sanitizedContainer = unsafeDocument.createElement('div');
|
||||
|
||||
for (const child of [...unsafeNode.childNodes]) {
|
||||
const sanitizedChildArray = sanitizeNode(
|
||||
child as Element,
|
||||
allowedTagsSet,
|
||||
allowedAttributeSets,
|
||||
keepChildrenWhenRemovingParent,
|
||||
);
|
||||
sanitizedChildArray.forEach((node) => {
|
||||
sanitizedContainer.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
let html = sanitizedContainer.innerHTML;
|
||||
|
||||
// Replace with regular spaces if removeNbsp option is enabled
|
||||
if (removeNbsp) {
|
||||
html = html.replace(/ /g, ' ');
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function sanitizeNode(
|
||||
node: Element,
|
||||
allowedTags: AllowedTags,
|
||||
allowedAttributes: AllowedAttributes,
|
||||
keepChildrenWhenRemovingParent: boolean,
|
||||
): Node[] | Element[] {
|
||||
// Plain text is safe as is
|
||||
// NOTE: The lowercase node (instead of Node) is intentional. Node is only
|
||||
// accessible in browser. In Node.js, it depends on jsdom (which we
|
||||
// avoid importing to exclude from the clientside vendor bundle).
|
||||
// Instead of passing down window.Node or jsdom.Node depending on
|
||||
// context, we rely on the fact that instances of Node (of which node
|
||||
// will be one) will also have these constants set on them.
|
||||
if (
|
||||
([node.TEXT_NODE, node.CDATA_SECTION_NODE] as number[]).includes(
|
||||
node.nodeType,
|
||||
)
|
||||
) {
|
||||
return [node];
|
||||
}
|
||||
|
||||
// Refuse anything that isn't a tag or one of the allowed tags
|
||||
const tagName = (node.tagName || '').toLowerCase();
|
||||
|
||||
if (!allowedTags.has(tagName)) {
|
||||
// when keepChildrenWhenRemovingParent is true
|
||||
// we check children for valid nodes as well
|
||||
if (keepChildrenWhenRemovingParent) {
|
||||
return sanitizeChildren(
|
||||
node,
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
keepChildrenWhenRemovingParent,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reconstruct node with only the allowedAttributes and sanitize its children
|
||||
const sanitized = node.ownerDocument.createElement(tagName);
|
||||
const currentlyAllowedAttributes = allowedAttributes[tagName] || new Set();
|
||||
|
||||
for (const { name, nodeValue: value } of [...node.attributes]) {
|
||||
if (currentlyAllowedAttributes.has(name)) {
|
||||
sanitized.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const children = sanitizeChildren(
|
||||
node,
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
keepChildrenWhenRemovingParent,
|
||||
);
|
||||
|
||||
children.forEach((child) => {
|
||||
sanitized.appendChild(child);
|
||||
});
|
||||
|
||||
return [sanitized];
|
||||
}
|
||||
|
||||
const sanitizeChildren = (
|
||||
node: Element,
|
||||
allowedTags: AllowedTags,
|
||||
allowedAttributes: AllowedAttributes,
|
||||
tagsToConvertToText: boolean,
|
||||
): Node[] => {
|
||||
const children = [...node.childNodes]
|
||||
.map((childNode) =>
|
||||
sanitizeNode(
|
||||
childNode as Element,
|
||||
allowedTags,
|
||||
allowedAttributes,
|
||||
tagsToConvertToText,
|
||||
),
|
||||
)
|
||||
.flat();
|
||||
|
||||
return children;
|
||||
};
|
||||
32
shared/components/src/utils/sanitize.ts
Normal file
32
shared/components/src/utils/sanitize.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
143
shared/components/src/utils/scrollByPolyfill.ts
Normal file
143
shared/components/src/utils/scrollByPolyfill.ts
Normal 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;
|
||||
}
|
||||
75
shared/components/src/utils/shelfAspectRatio.ts
Normal file
75
shared/components/src/utils/shelfAspectRatio.ts
Normal 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();
|
||||
};
|
||||
25
shared/components/src/utils/should-show-navigation-item.ts
Normal file
25
shared/components/src/utils/should-show-navigation-item.ts
Normal 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;
|
||||
}
|
||||
49
shared/components/src/utils/throttle.ts
Normal file
49
shared/components/src/utils/throttle.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
71
shared/components/src/utils/uniqueId.ts
Normal file
71
shared/components/src/utils/uniqueId.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user