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