main code
This commit is contained in:
1
api/csp-report.html
Normal file
1
api/csp-report.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
No Content: https://apps.apple.com/api/csp-report
|
||||||
BIN
assets/favicon/favicon-32.png
Normal file
BIN
assets/favicon/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
157
assets/focus-visible/focus-visible.min.js
vendored
Normal file
157
assets/focus-visible/focus-visible.min.js
vendored
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
!(function (e, t) {
|
||||||
|
'object' == typeof exports && 'undefined' != typeof module
|
||||||
|
? t()
|
||||||
|
: 'function' == typeof define && define.amd
|
||||||
|
? define(t)
|
||||||
|
: t();
|
||||||
|
})(0, function () {
|
||||||
|
'use strict';
|
||||||
|
function e(e) {
|
||||||
|
var t = !0,
|
||||||
|
n = !1,
|
||||||
|
o = null,
|
||||||
|
d = {
|
||||||
|
text: !0,
|
||||||
|
search: !0,
|
||||||
|
url: !0,
|
||||||
|
tel: !0,
|
||||||
|
email: !0,
|
||||||
|
password: !0,
|
||||||
|
number: !0,
|
||||||
|
date: !0,
|
||||||
|
month: !0,
|
||||||
|
week: !0,
|
||||||
|
time: !0,
|
||||||
|
datetime: !0,
|
||||||
|
'datetime-local': !0,
|
||||||
|
};
|
||||||
|
function i(e) {
|
||||||
|
return !!(
|
||||||
|
e &&
|
||||||
|
e !== document &&
|
||||||
|
'HTML' !== e.nodeName &&
|
||||||
|
'BODY' !== e.nodeName &&
|
||||||
|
'classList' in e &&
|
||||||
|
'contains' in e.classList
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function s(e) {
|
||||||
|
e.classList.contains('focus-visible') ||
|
||||||
|
(e.classList.add('focus-visible'),
|
||||||
|
e.setAttribute('data-focus-visible-added', ''));
|
||||||
|
}
|
||||||
|
function u(e) {
|
||||||
|
t = !1;
|
||||||
|
}
|
||||||
|
function a() {
|
||||||
|
document.addEventListener('mousemove', c),
|
||||||
|
document.addEventListener('mousedown', c),
|
||||||
|
document.addEventListener('mouseup', c),
|
||||||
|
document.addEventListener('pointermove', c),
|
||||||
|
document.addEventListener('pointerdown', c),
|
||||||
|
document.addEventListener('pointerup', c),
|
||||||
|
document.addEventListener('touchmove', c),
|
||||||
|
document.addEventListener('touchstart', c),
|
||||||
|
document.addEventListener('touchend', c);
|
||||||
|
}
|
||||||
|
function c(e) {
|
||||||
|
(e.target.nodeName && 'html' === e.target.nodeName.toLowerCase()) ||
|
||||||
|
((t = !1),
|
||||||
|
document.removeEventListener('mousemove', c),
|
||||||
|
document.removeEventListener('mousedown', c),
|
||||||
|
document.removeEventListener('mouseup', c),
|
||||||
|
document.removeEventListener('pointermove', c),
|
||||||
|
document.removeEventListener('pointerdown', c),
|
||||||
|
document.removeEventListener('pointerup', c),
|
||||||
|
document.removeEventListener('touchmove', c),
|
||||||
|
document.removeEventListener('touchstart', c),
|
||||||
|
document.removeEventListener('touchend', c));
|
||||||
|
}
|
||||||
|
document.addEventListener(
|
||||||
|
'keydown',
|
||||||
|
function (n) {
|
||||||
|
n.metaKey ||
|
||||||
|
n.altKey ||
|
||||||
|
n.ctrlKey ||
|
||||||
|
(i(e.activeElement) && s(e.activeElement), (t = !0));
|
||||||
|
},
|
||||||
|
!0,
|
||||||
|
),
|
||||||
|
document.addEventListener('mousedown', u, !0),
|
||||||
|
document.addEventListener('pointerdown', u, !0),
|
||||||
|
document.addEventListener('touchstart', u, !0),
|
||||||
|
document.addEventListener(
|
||||||
|
'visibilitychange',
|
||||||
|
function (e) {
|
||||||
|
'hidden' === document.visibilityState &&
|
||||||
|
(n && (t = !0), a());
|
||||||
|
},
|
||||||
|
!0,
|
||||||
|
),
|
||||||
|
a(),
|
||||||
|
e.addEventListener(
|
||||||
|
'focus',
|
||||||
|
function (e) {
|
||||||
|
var n, o, u;
|
||||||
|
i(e.target) &&
|
||||||
|
(t ||
|
||||||
|
((n = e.target),
|
||||||
|
(o = n.type),
|
||||||
|
('INPUT' === (u = n.tagName) &&
|
||||||
|
d[o] &&
|
||||||
|
!n.readOnly) ||
|
||||||
|
('TEXTAREA' === u && !n.readOnly) ||
|
||||||
|
n.isContentEditable)) &&
|
||||||
|
s(e.target);
|
||||||
|
},
|
||||||
|
!0,
|
||||||
|
),
|
||||||
|
e.addEventListener(
|
||||||
|
'blur',
|
||||||
|
function (e) {
|
||||||
|
var t;
|
||||||
|
i(e.target) &&
|
||||||
|
(e.target.classList.contains('focus-visible') ||
|
||||||
|
e.target.hasAttribute(
|
||||||
|
'data-focus-visible-added',
|
||||||
|
)) &&
|
||||||
|
((n = !0),
|
||||||
|
window.clearTimeout(o),
|
||||||
|
(o = window.setTimeout(function () {
|
||||||
|
n = !1;
|
||||||
|
}, 100)),
|
||||||
|
(t = e.target).hasAttribute(
|
||||||
|
'data-focus-visible-added',
|
||||||
|
) &&
|
||||||
|
(t.classList.remove('focus-visible'),
|
||||||
|
t.removeAttribute('data-focus-visible-added')));
|
||||||
|
},
|
||||||
|
!0,
|
||||||
|
),
|
||||||
|
e.nodeType === Node.DOCUMENT_FRAGMENT_NODE && e.host
|
||||||
|
? e.host.setAttribute('data-js-focus-visible', '')
|
||||||
|
: e.nodeType === Node.DOCUMENT_NODE &&
|
||||||
|
(document.documentElement.classList.add('js-focus-visible'),
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
'data-js-focus-visible',
|
||||||
|
'',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ('undefined' != typeof window && 'undefined' != typeof document) {
|
||||||
|
var t;
|
||||||
|
window.applyFocusVisiblePolyfill = e;
|
||||||
|
try {
|
||||||
|
t = new CustomEvent('focus-visible-polyfill-ready');
|
||||||
|
} catch (e) {
|
||||||
|
(t = document.createEvent('CustomEvent')).initCustomEvent(
|
||||||
|
'focus-visible-polyfill-ready',
|
||||||
|
!1,
|
||||||
|
!1,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(t);
|
||||||
|
}
|
||||||
|
'undefined' != typeof document && e(document);
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=focus-visible.min.js.map
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
No Content: https://apps.apple.com/assets/fonts/locale-switcher/ArabicUIText-Regular-subset.woff2
|
||||||
1
assets/icons/sidebar-searchfield-close-on-dark.svg
Normal file
1
assets/icons/sidebar-searchfield-close-on-dark.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg height="14" viewBox="0 0 14 14" width="14" xmlns="http://www.w3.org/2000/svg"><path d="m7 0c3.8659932 0 7 3.13400675 7 7 0 3.8659932-3.1340068 7-7 7-3.86599325 0-7-3.1340068-7-7 0-3.86599325 3.13400675-7 7-7zm3.2615892 3.73841079c-.31788105-.31788105-.83326807-.31788105-1.15114912 0l-2.11044008 2.11058921-2.11044008-2.11058921c-.31788105-.31788105-.83326807-.31788105-1.15114913 0-.31788105.31788106-.31788105.83326808 0 1.15114913l2.11058921 2.11044008-2.11058921 2.11044008-.07284774.08335637c-.24282581.31905095-.21854323.77640179.07284774 1.06779275l.08335638.0728478c.31905094.2428258.77640179.2185432 1.06779275-.0728478l2.11044008-2.1105892 2.11044008 2.1105892c.31788105.3178811.83326807.3178811 1.15114912 0 .3178811-.31788105.3178811-.83326807 0-1.15114912l-2.1105892-2.11044008 2.1105892-2.11044008.0728478-.08335637c.2428258-.31905095.2185432-.77640179-.0728478-1.06779276z" fill="#fff" fill-opacity=".6" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 951 B |
3
assets/indexB87DnNzwx-.js
Normal file
3
assets/indexB87DnNzwx-.js
Normal file
File diff suppressed because one or more lines are too long
2
assets/indexBRTBTu87nN.js
Normal file
2
assets/indexBRTBTu87nN.js
Normal file
File diff suppressed because one or more lines are too long
1
assets/indexC-lllyUtRG.css
Normal file
1
assets/indexC-lllyUtRG.css
Normal file
File diff suppressed because one or more lines are too long
2
assets/indexC16D7IckKX.js
Normal file
2
assets/indexC16D7IckKX.js
Normal file
File diff suppressed because one or more lines are too long
2
assets/mt-client-logger-core.esm-rJfHcY8Zf.js
Normal file
2
assets/mt-client-logger-core.esm-rJfHcY8Zf.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import{r as i,a as _}from"./mt-metricskit-utils-private.esm~DkzakSThTT.js";function f(t){this.key=t}f.prototype.toString=function(){return this.key};var l={flagArguments:{INCLUDE_CALL_STACK:new f("INCLUDE_CALL_STACK"),MIRROR_TO_SERVER:new f("MIRROR_TO_SERVER"),SUPPRESS_CLIENT_OUTPUT:new f("SUPPRESS_CLIENT_OUTPUT")},setDelegate:function(e){return i.attachDelegate(this,e)},execute:function(e,n,o){var a=e.levelStringToIntMap[n];if(e.level()!==e.NONE&&e.level()<=a){var u=Array.prototype.slice.call(o),s=l.nonFlagLogArguments(u),v=l.logOptions(e,a,u),h=v.includeCallStack?new Error().stack:null,c=h?s.concat("\n"+h):s;if(e[n]._lastLog=c,v.mirrorToServer,v.throwInsteadOfPrint)throw new Error(s.toString());v.suppressClientOutput||(console[n]?console[n].apply(console,c):console.log.apply(console,c))}},isFlagObject:function(e){return e&&e===l.flagArguments[e.toString()]},nonFlagLogArguments:function(e){return e.filter(function(n){return!l.isFlagObject(n)})},logOptions:function(e,n,o){var a={},u;return o.forEach(function(s){l.isFlagObject(s)&&(u=_.snakeCaseToCamelCase(s.toString()),a[u]=!0)}),i.isFunction(e.mirrorToServerLevel)&&e.mirrorToServerLevel()!==e.NONE&&e.mirrorToServerLevel()<=n&&(a.mirrorToServer=!0),e.throwLevel()!==e.NONE&&e.throwLevel()<=n&&(a.throwInsteadOfPrint=!0),a},sendToServer:function(e,n,o,a){}},p={NONE:0,DEBUG:1,INFO:2,WARN:3,ERROR:4},L={MIN_LEVEL:p.NONE,MAX_LEVEL:p.ERROR,levelIntToStringMap:{0:"none",1:"debug",2:"info",3:"warn",4:"error"},levelStringToIntMap:{none:0,debug:1,info:2,warn:3,error:4}};i.extend(L,p);var g={loggerName:"defaultLogger",level:L.INFO,throwLevel:L.NONE},E=!1,S={};function r(t){this._loggerName=t,this._level,this._throwLevel,E||(E=!0,i.extend(r.prototype,L),i.extend(r.prototype,l.flagArguments))}function O(t){t=t||g.loggerName;var e=S[t];return e||(e=new r(t),S[t]=e),e}r.level=function(){return g.level};r.throwLevel=function(){return g.throwLevel};r.prototype.setDelegate=function(e){return i.attachDelegate(this,e)};r.prototype.loggerName=function(){return this._loggerName};r.prototype.levelParameterAsInt=function(e){var n=null,o;return i.isString(e)?o=this.levelStringToIntMap[e.toLowerCase()]:i.isNumber(e)&&(o=e),o>=this.MIN_LEVEL&&o<=this.MAX_LEVEL&&(n=o),n};r.prototype.setLevel=function(e){var n=this.levelParameterAsInt(e);n!==null&&(this._level=n)};r.prototype.setThrowLevel=function(e){var n=this.levelParameterAsInt(e);n!==null&&(this._throwLevel=n)};r.prototype.level=function(){var e=this._level;return i.isNumber(e)?e:r.level()};r.prototype.levelString=function(){return this.levelIntToStringMap[this.level()]};r.prototype.throwLevel=function(){var e=this._throwLevel;return i.isNumber(e)?e:r.throwLevel()};r.prototype.debug=function(){l.execute(this,"debug",arguments)};r.prototype.info=function(){l.execute(this,"info",arguments)};r.prototype.warn=function(){l.execute(this,"warn",arguments)};r.prototype.error=function(){l.execute(this,"error",arguments)};r.prototype.lastLog=function(e){return this[e]?this[e]._lastLog:null};export{r as L,O as l};
|
||||||
|
//# sourceMappingURL=mt-client-logger-core.esm~-rJfHcY8Zf.js.map
|
||||||
2
assets/mt-metricskit-delegates-web.esmC4cdYnvnOP.js
Normal file
2
assets/mt-metricskit-delegates-web.esmC4cdYnvnOP.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
assets/mt-metricskit-utils-private.esmDkzakSThTT.js
Normal file
2
assets/mt-metricskit-utils-private.esmDkzakSThTT.js
Normal file
File diff suppressed because one or more lines are too long
2
assets/translationsrCrRWHpZfd.js
Normal file
2
assets/translationsrCrRWHpZfd.js
Normal file
File diff suppressed because one or more lines are too long
2
assets/versionBNUm1OH-k3.js
Normal file
2
assets/versionBNUm1OH-k3.js
Normal file
File diff suppressed because one or more lines are too long
57
shared/apps-common/src/jet/dependencies/host.ts
Normal file
57
shared/apps-common/src/jet/dependencies/host.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type {
|
||||||
|
ClientIdentifier,
|
||||||
|
Host as NativeHost,
|
||||||
|
ProcessPlatform,
|
||||||
|
} from '@jet/environment';
|
||||||
|
import type {} from '@jet/engine'; // For ClientIdentifier.Unknown
|
||||||
|
|
||||||
|
export class Host implements NativeHost {
|
||||||
|
platform: ProcessPlatform = 'web';
|
||||||
|
|
||||||
|
get osBuild(): never {
|
||||||
|
throw makeWebDoesNotImplementException('osBuild');
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceModel(): string {
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
get devicePhysicalModel(): never {
|
||||||
|
throw makeWebDoesNotImplementException('devicePhysicalModel');
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceLocalizedModel() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceModelFamily(): never {
|
||||||
|
throw makeWebDoesNotImplementException('deviceModelFamily');
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientIdentifier(): ClientIdentifier {
|
||||||
|
// We can't directly use the `ClientIdentifier.Unknown` enum member value
|
||||||
|
// because we cannot access "ambient const enums" with our TypeScript config.
|
||||||
|
// Enum handling is known to be tough in TypeScript and, for reasons like
|
||||||
|
// this, they are generally avoided.
|
||||||
|
// This returns a value defined on this enum by `@jet/engine`'s type definition
|
||||||
|
return 'unknown' as ClientIdentifier.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
get clientVersion(): never {
|
||||||
|
throw makeWebDoesNotImplementException('clientVersion');
|
||||||
|
}
|
||||||
|
|
||||||
|
isOSAtLeast(
|
||||||
|
_majorVersion: number,
|
||||||
|
_minorVersion: number,
|
||||||
|
_patchVersion: number,
|
||||||
|
): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeWebDoesNotImplementException(property: keyof NativeHost) {
|
||||||
|
return new Error(
|
||||||
|
`\`Host\` property \`${property}\` is not implemented for the "web" platform`,
|
||||||
|
);
|
||||||
|
}
|
||||||
18
shared/apps-common/src/jet/dependencies/random.ts
Normal file
18
shared/apps-common/src/jet/dependencies/random.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Random as IRandom } from '@jet/environment';
|
||||||
|
import { generateUuid } from '@amp/web-apps-utils';
|
||||||
|
|
||||||
|
export class Random implements IRandom {
|
||||||
|
nextBoolean(): boolean {
|
||||||
|
// See: https://stashweb.sd.apple.com/projects/AS/repos/jet-infrastructure/browse/Frameworks/JetEngine/JetEngine/JavaScript/Stack/Native%20APIs/JSRandomObject.swift?at=e90a88fa061f5cb6b9536d29a7ffd67e5db942db#41
|
||||||
|
return Math.random() < 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextNumber(): number {
|
||||||
|
// See: https://stashweb.sd.apple.com/projects/AS/repos/jet-infrastructure/browse/Frameworks/JetEngine/JetEngine/JavaScript/Stack/Native%20APIs/JSRandomObject.swift?at=e90a88fa061f5cb6b9536d29a7ffd67e5db942db#45
|
||||||
|
return Math.random();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUUID(): string {
|
||||||
|
return generateUuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { getCookie } from '@amp/web-app-components/src/utils/cookie';
|
||||||
|
import type { LoggerFactory } from '@amp/web-apps-logger';
|
||||||
|
import { isSome } from '@amp/web-apps-utils';
|
||||||
|
import { deserializeServerData, stableStringify } from './server-data';
|
||||||
|
import { type PrefetchedIntent, isPrefetchedIntents } from './types';
|
||||||
|
|
||||||
|
export function getPrefetchedIntents(
|
||||||
|
loggerFactory: LoggerFactory,
|
||||||
|
options?: { evenIfSignedIn?: boolean; featureKitItfe?: string },
|
||||||
|
): Map<string, unknown> {
|
||||||
|
const logger = loggerFactory.loggerFor('getPrefetchedIntents');
|
||||||
|
const evenIfSignedIn = options?.evenIfSignedIn;
|
||||||
|
const itfe = options?.featureKitItfe;
|
||||||
|
|
||||||
|
const data = deserializeServerData();
|
||||||
|
if (!data || !isPrefetchedIntents(data)) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// We avoid prefetched intents in two scenarios:
|
||||||
|
//
|
||||||
|
// Condition 1: User is signed in (and evenIfSignedIn is false)
|
||||||
|
// It's possible/likely that dispatching an intent when signed in behaves
|
||||||
|
// differently.
|
||||||
|
//
|
||||||
|
// Condition 2: ITFE is enabled in Feature Kit
|
||||||
|
// When ITFE is active, we discard prefetched intents so that media API
|
||||||
|
// calls are triggered in the browser, allowing Feature Kit to inject ITFE
|
||||||
|
// into those calls.
|
||||||
|
if ((!evenIfSignedIn && getCookie('media-user-token')) || itfe) {
|
||||||
|
logger.info(
|
||||||
|
'Discarding prefetched intents - signed in user or ITFE enabled',
|
||||||
|
);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('received prefetched intents from the server:', data);
|
||||||
|
return new Map(
|
||||||
|
data
|
||||||
|
.map(
|
||||||
|
({
|
||||||
|
intent,
|
||||||
|
data,
|
||||||
|
}: PrefetchedIntent): [string, unknown] | null => {
|
||||||
|
try {
|
||||||
|
if (intent.$kind.includes('Library')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// NOTE: PrefetchedIntents.get depends on stableStringify
|
||||||
|
return [stableStringify(intent), data];
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.filter(isSome),
|
||||||
|
);
|
||||||
|
}
|
||||||
118
shared/apps-common/src/jet/prefetched-intents/index.ts
Normal file
118
shared/apps-common/src/jet/prefetched-intents/index.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { LoggerFactory } from '@amp/web-apps-logger';
|
||||||
|
import type { Intent, IntentReturnType } from '@jet/environment/dispatching';
|
||||||
|
import { serializeServerData, stableStringify } from './server-data';
|
||||||
|
import type { PrefetchedIntent } from './types';
|
||||||
|
import { getPrefetchedIntents } from './get-prefetched-intents';
|
||||||
|
|
||||||
|
export type { PrefetchedIntent } from './types';
|
||||||
|
|
||||||
|
export function serializePrefetchedIntents(
|
||||||
|
loggerFactory: LoggerFactory,
|
||||||
|
prefetchedIntents: PrefetchedIntent[],
|
||||||
|
): string {
|
||||||
|
const serialized = serializeServerData(
|
||||||
|
prefetchedIntents.map(removeSeoData),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (serialized.length === 0) {
|
||||||
|
const logger = loggerFactory.loggerFor('serializePrefetchedIntents');
|
||||||
|
logger.warn('failed to serialize prefetched intents');
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO data is never needed for the first clientside render since the server
|
||||||
|
// already adds SEO tags. The seoData convention is ubiquitous across the apps.
|
||||||
|
// See: rdar://144581413 (Etag constantly changes on pages with songs due to seoData.ogSongs)
|
||||||
|
function removeSeoData(intent: PrefetchedIntent): PrefetchedIntent {
|
||||||
|
const { data } = intent;
|
||||||
|
|
||||||
|
// We very intentionally return the original intent to prevent
|
||||||
|
// needlessly allocating new objects.
|
||||||
|
|
||||||
|
if (data === null || typeof data !== 'object' || !('seoData' in data)) {
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { seoData } = data;
|
||||||
|
if (seoData === null || typeof seoData !== 'object') {
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let partialSeoData:
|
||||||
|
| { pageTitle?: unknown; titleHeader?: unknown }
|
||||||
|
| undefined = undefined;
|
||||||
|
if ('pageTitle' in seoData || 'titleHeader' in seoData) {
|
||||||
|
partialSeoData = {};
|
||||||
|
|
||||||
|
if ('pageTitle' in seoData) {
|
||||||
|
partialSeoData['pageTitle'] = seoData.pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('titleHeader' in seoData) {
|
||||||
|
partialSeoData['titleHeader'] = seoData.titleHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only if we're actually going to do the removal do we spread
|
||||||
|
return {
|
||||||
|
...intent,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
// Page title is desirable to keep as it is occasionally consulted
|
||||||
|
// outside of MetaTags.svelte
|
||||||
|
seoData: partialSeoData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PrefetchedIntents {
|
||||||
|
static empty(): PrefetchedIntents {
|
||||||
|
return new PrefetchedIntents(new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromDom(
|
||||||
|
loggerFactory: LoggerFactory,
|
||||||
|
options?: { evenIfSignedIn?: boolean; featureKitItfe?: string },
|
||||||
|
): PrefetchedIntents {
|
||||||
|
return new PrefetchedIntents(
|
||||||
|
getPrefetchedIntents(loggerFactory, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private intents: Map<string, unknown>;
|
||||||
|
|
||||||
|
private constructor(intents: Map<string, unknown>) {
|
||||||
|
this.intents = intents;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<I extends Intent<unknown>>(intent: I): IntentReturnType<I> | undefined {
|
||||||
|
if (this.intents.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subject: string | void;
|
||||||
|
try {
|
||||||
|
subject = stableStringify(intent);
|
||||||
|
} catch (e) {
|
||||||
|
// It's possible the intents don't stringify. If that's that case,
|
||||||
|
// then we won't find it in this.intents, since the keys of that
|
||||||
|
// are successfully stringified intents. We could try something
|
||||||
|
// sophisticated here, but it's probably not worth it as most
|
||||||
|
// intents will serialize.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = this.intents.get(subject);
|
||||||
|
|
||||||
|
// Remove the prefetched data so that it can only be used once
|
||||||
|
this.intents.delete(subject);
|
||||||
|
|
||||||
|
// NOTE: There really isn't a good way to be safe with types here. We
|
||||||
|
// don't have a type guard for arbitrary IntentReturnType<I>. We just
|
||||||
|
// have to trust that the serialized data is of the correct type. This
|
||||||
|
// isn't unreasonable since we control serialization.
|
||||||
|
return data as unknown as IntentReturnType<I> | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
shared/apps-common/src/jet/prefetched-intents/server-data.ts
Normal file
109
shared/apps-common/src/jet/prefetched-intents/server-data.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { isPOJO } from '@amp/web-apps-utils';
|
||||||
|
|
||||||
|
// NOTE: be careful with imports here. This file is imported by browser code,
|
||||||
|
// so we expect tree shaking to only keep these functions.
|
||||||
|
|
||||||
|
const SERVER_DATA_ID = 'serialized-server-data';
|
||||||
|
|
||||||
|
// 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 serializeServerData(data: object): string {
|
||||||
|
try {
|
||||||
|
const sanitizedData = JSON.stringify(data).replace(
|
||||||
|
pattern,
|
||||||
|
(match) => replacements[match as keyof typeof replacements],
|
||||||
|
);
|
||||||
|
return `<script type="application/json" id="${SERVER_DATA_ID}">${sanitizedData}</script>`;
|
||||||
|
} 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes data serialized on the server by `serializeServerData`.
|
||||||
|
*
|
||||||
|
* @returns deserialized data (or undefined if it doesn't exist/errors)
|
||||||
|
*/
|
||||||
|
export function deserializeServerData(): ReturnType<JSON['parse']> | undefined {
|
||||||
|
const script = document.getElementById(SERVER_DATA_ID);
|
||||||
|
if (!script) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
script.parentNode?.removeChild(script);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(script.textContent || '');
|
||||||
|
} catch (e) {
|
||||||
|
// If the content is malformed, we want to avoid throwing. This
|
||||||
|
// situation should be impossible since we control the serialization
|
||||||
|
// above.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON stringify a POJO value in a stable manner. Specifically, this means that
|
||||||
|
* objects which are structurally equal serialize to the same string.
|
||||||
|
*
|
||||||
|
* This is useful when comparing objects serialized by a server against objects
|
||||||
|
* build in browser. With plain JSON.stringify(), property order matters and is
|
||||||
|
* not guaranteed to be the same. In other words these two objects would
|
||||||
|
* JSON.stringify() differently:
|
||||||
|
*
|
||||||
|
* { a: 1, b: 2 }
|
||||||
|
* { b: 2, a: 1 }
|
||||||
|
*
|
||||||
|
* But these are structurally equal--they have the same keys and values.
|
||||||
|
*
|
||||||
|
* The expected use case for this function is generating keys for a Map for
|
||||||
|
* objects from a server that will be compared against objects from the browser.
|
||||||
|
* This function should be used on objects returned from `deserializeServerData`
|
||||||
|
* before they are used in such contexts.
|
||||||
|
*
|
||||||
|
* See: https://stackoverflow.com/a/43049877
|
||||||
|
*/
|
||||||
|
export function stableStringify(data: unknown): string {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const items = data.map(stableStringify).join(',');
|
||||||
|
return `[${items}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort object keys before serializing
|
||||||
|
if (isPOJO(data)) {
|
||||||
|
const keys = [...Object.keys(data)];
|
||||||
|
keys.sort();
|
||||||
|
|
||||||
|
const properties = keys
|
||||||
|
// undefined values should not get included in stringification
|
||||||
|
.filter((key) => typeof data[key] !== 'undefined')
|
||||||
|
.map(
|
||||||
|
(key) => `${JSON.stringify(key)}:${stableStringify(data[key])}`,
|
||||||
|
)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
return `{${properties}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
27
shared/apps-common/src/jet/prefetched-intents/types.ts
Normal file
27
shared/apps-common/src/jet/prefetched-intents/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Intent } from '@jet/environment/dispatching';
|
||||||
|
|
||||||
|
export interface PrefetchedIntent {
|
||||||
|
intent: Intent<unknown>;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrefetchedIntents(v: unknown): v is PrefetchedIntent[] {
|
||||||
|
return Array.isArray(v) && v.every(isPrefetchedIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrefetchedIntent(v: unknown): v is PrefetchedIntent {
|
||||||
|
return hasIntentAndData(v) && isIntent(v.intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIntentAndData(v: unknown): v is HasIntentAndData {
|
||||||
|
return v !== null && typeof v === 'object' && 'intent' in v && 'data' in v;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HasIntentAndData {
|
||||||
|
intent: unknown;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIntent(v: unknown): v is Intent<unknown> {
|
||||||
|
return v !== null && typeof v === 'object' && '$kind' in v;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user