From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- shared/apps-common/src/jet/dependencies/host.ts | 57 + shared/apps-common/src/jet/dependencies/random.ts | 18 + .../prefetched-intents/get-prefetched-intents.ts | 58 + .../src/jet/prefetched-intents/index.ts | 118 + .../src/jet/prefetched-intents/server-data.ts | 109 + .../src/jet/prefetched-intents/types.ts | 27 + shared/components/assets/icons/arrow.svg | 1 + shared/components/assets/icons/chevron.svg | 1 + shared/components/assets/icons/close.svg | 1 + shared/components/assets/icons/search.svg | 1 + shared/components/assets/icons/star-filled.svg | 1 + shared/components/assets/icons/star-hollow.svg | 1 + .../assets/shelf/chevron-compact-left.svg | 1 + shared/components/config/components/artwork.ts | 103 + shared/components/config/components/shelf.ts | 116 + .../dist/intersection-observer-admin.es5.js | 428 + shared/components/src/actions/allow-drag.ts | 291 + shared/components/src/actions/allow-drop.ts | 249 + shared/components/src/actions/click-outside.ts | 18 + .../components/src/actions/focus-node-on-mount.ts | 5 + shared/components/src/actions/focus-node.ts | 19 + .../src/actions/intersection-observer.ts | 100 + .../components/src/actions/list-keyboard-access.ts | 351 + .../updateScrollAndWindowDependentVisuals.ts | 48 + .../src/components/Artwork/Artwork.svelte | 565 ++ .../components/src/components/Artwork/constants.ts | 227 + .../components/Artwork/loaders/LazyLoader.svelte | 89 + .../Artwork/loaders/LoaderSelector.svelte | 38 + .../src/components/Artwork/loaders/NoLoader.svelte | 20 + .../src/components/Artwork/stores/artworkLoader.ts | 30 + .../src/components/Artwork/utils/artProfile.ts | 77 + .../src/components/Artwork/utils/preconnect.ts | 64 + .../Artwork/utils/replaceQualityParam.ts | 66 + .../src/components/Artwork/utils/srcset.ts | 467 ++ .../components/Artwork/utils/validateBackground.ts | 16 + .../src/components/Error/ErrorPage.svelte | 83 + .../components/src/components/Footer/Footer.svelte | 195 + .../src/components/LineClamp/LineClamp.svelte | 238 + .../LoadingSpinner/LoadingSpinner.svelte | 260 + .../src/components/MetaTags/MetaTags.svelte | 262 + .../src/components/Modal/ContentModal.svelte | 222 + .../LocaleSwitcherModal/LocaleSwitcherModal.svelte | 281 + .../LocaleSwitcherRegion.svelte | 27 + .../LocaleSwitcherRegionList.svelte | 70 + .../components/src/components/Modal/Modal.svelte | 246 + .../src/components/Navigation/Folder.svelte | 277 + .../src/components/Navigation/Item.svelte | 183 + .../src/components/Navigation/ItemContent.svelte | 71 + .../src/components/Navigation/MenuIcon.svelte | 178 + .../src/components/Navigation/Navigation.svelte | 298 + .../components/Navigation/NavigationItems.svelte | 281 + .../src/components/Navigation/store/menu-state.ts | 4 + .../components/src/components/Navigation/utils.ts | 27 + .../components/src/components/Rating/Rating.svelte | 141 + shared/components/src/components/Rating/utils.ts | 10 + .../src/components/SearchInput/SearchInput.svelte | 530 ++ .../SearchSuggestions/SearchSuggestions.svelte | 331 + shared/components/src/components/Shelf/Nav.svelte | 199 + .../components/src/components/Shelf/Shelf.svelte | 535 ++ .../src/components/Shelf/ShelfItem.svelte | 60 + .../src/components/Shelf/actions/observe.ts | 31 + .../components/src/components/Shelf/constants.ts | 20 + .../src/components/Shelf/store/visibleStore.ts | 33 + .../src/components/Shelf/utils/getGridVars.ts | 98 + .../components/Shelf/utils/getMaxVisibleItems.ts | 19 + .../src/components/Shelf/utils/observerCallback.ts | 30 + .../src/components/Shelf/utils/shelf-window.ts | 67 + .../TextSearchSuggestion.svelte | 44 + .../src/components/Truncate/Truncate.svelte | 222 + .../src/components/buttons/Button.svelte | 324 + .../LocaleSwitcherButton.svelte | 99 + .../LocaleSwitcherLanguages.svelte | 100 + .../src/components/helpers/ResizeDetector.svelte | 30 + shared/components/src/constants.ts | 53 + shared/components/src/stores/media-query.ts | 63 + .../src/stores/navigation-folders-open.ts | 21 + .../src/stores/prefers-reduced-motion.ts | 27 + shared/components/src/stores/sidebar-hidden.ts | 12 + shared/components/src/utils/cookie.ts | 71 + shared/components/src/utils/date.ts | 51 + shared/components/src/utils/debounce.ts | 40 + shared/components/src/utils/getMediaConditions.ts | 117 + shared/components/src/utils/getStorefrontRoute.ts | 29 + .../components/src/utils/getUpdatedFocusedIndex.ts | 25 + .../components/src/utils/internal/locale/index.ts | 17 + shared/components/src/utils/makeSafeTick.ts | 64 + shared/components/src/utils/memoize.ts | 26 + shared/components/src/utils/rafQueue.ts | 74 + .../components/src/utils/sanitize-html/browser.ts | 26 + .../components/src/utils/sanitize-html/common.ts | 176 + shared/components/src/utils/sanitize.ts | 32 + shared/components/src/utils/scrollByPolyfill.ts | 143 + shared/components/src/utils/shelfAspectRatio.ts | 75 + .../src/utils/should-show-navigation-item.ts | 25 + shared/components/src/utils/throttle.ts | 49 + shared/components/src/utils/uniqueId.ts | 71 + shared/featurekit/src/is-enabled.ts | 7 + shared/fonts/src/index.ts | 53 + .../node_modules/make-plural/cardinals.mjs | 458 ++ shared/localization/src/getLocAttributes.ts | 78 + shared/localization/src/getPageDir.ts | 40 + shared/localization/src/i18n.ts | 104 + shared/localization/src/setHTMLAttributes.ts | 15 + shared/localization/src/translator.ts | 174 + .../@amp-metrics/sentrykit/dist/index.mjs | 815 ++ .../tracing/esm/browser/backgroundtab.js | 36 + .../tracing/esm/browser/browsertracing.js | 300 + .../tracing/esm/browser/metrics/index.js | 484 ++ .../tracing/esm/browser/metrics/utils.js | 25 + .../tracing/esm/browser/request.js | 335 + .../@sentry-internal/tracing/esm/browser/router.js | 64 + .../@sentry-internal/tracing/esm/browser/types.js | 6 + .../tracing/esm/browser/web-vitals/getCLS.js | 105 + .../tracing/esm/browser/web-vitals/getFID.js | 63 + .../tracing/esm/browser/web-vitals/getLCP.js | 85 + .../esm/browser/web-vitals/lib/bindReporter.js | 28 + .../esm/browser/web-vitals/lib/generateUniqueID.js | 27 + .../browser/web-vitals/lib/getActivationStart.js | 25 + .../browser/web-vitals/lib/getNavigationEntry.js | 53 + .../browser/web-vitals/lib/getVisibilityWatcher.js | 54 + .../esm/browser/web-vitals/lib/initMetric.js | 46 + .../tracing/esm/browser/web-vitals/lib/observe.js | 37 + .../tracing/esm/browser/web-vitals/lib/onHidden.js | 36 + .../node_modules/@sentry/browser/esm/client.js | 139 + .../@sentry/browser/esm/eventbuilder.js | 304 + .../node_modules/@sentry/browser/esm/helpers.js | 154 + .../node_modules/@sentry/browser/esm/index.js | 39 + .../browser/esm/integrations/breadcrumbs.js | 320 + .../@sentry/browser/esm/integrations/dedupe.js | 211 + .../browser/esm/integrations/globalhandlers.js | 248 + .../browser/esm/integrations/httpcontext.js | 47 + .../browser/esm/integrations/linkederrors.js | 87 + .../@sentry/browser/esm/integrations/trycatch.js | 281 + .../@sentry/browser/esm/profiling/hubextensions.js | 240 + .../@sentry/browser/esm/profiling/integration.js | 81 + .../@sentry/browser/esm/profiling/utils.js | 438 + .../logger/node_modules/@sentry/browser/esm/sdk.js | 293 + .../@sentry/browser/esm/stack-parsers.js | 168 + .../@sentry/browser/esm/transports/fetch.js | 64 + .../@sentry/browser/esm/transports/offline.js | 133 + .../@sentry/browser/esm/transports/utils.js | 85 + .../@sentry/browser/esm/transports/xhr.js | 52 + .../@sentry/browser/esm/userfeedback.js | 41 + shared/logger/node_modules/@sentry/core/esm/api.js | 90 + .../node_modules/@sentry/core/esm/baseclient.js | 674 ++ .../node_modules/@sentry/core/esm/constants.js | 4 + .../node_modules/@sentry/core/esm/envelope.js | 74 + .../node_modules/@sentry/core/esm/exports.js | 193 + shared/logger/node_modules/@sentry/core/esm/hub.js | 564 ++ .../node_modules/@sentry/core/esm/integration.js | 112 + .../core/esm/integrations/functiontostring.js | 39 + .../core/esm/integrations/inboundfilters.js | 213 + .../logger/node_modules/@sentry/core/esm/scope.js | 555 ++ shared/logger/node_modules/@sentry/core/esm/sdk.js | 35 + .../node_modules/@sentry/core/esm/session.js | 155 + .../@sentry/core/esm/tracing/errors.js | 36 + .../@sentry/core/esm/tracing/hubextensions.js | 241 + .../@sentry/core/esm/tracing/idletransaction.js | 347 + .../node_modules/@sentry/core/esm/tracing/span.js | 378 + .../node_modules/@sentry/core/esm/tracing/trace.js | 78 + .../@sentry/core/esm/tracing/transaction.js | 276 + .../node_modules/@sentry/core/esm/tracing/utils.js | 12 + .../@sentry/core/esm/transports/base.js | 101 + .../@sentry/core/esm/transports/multiplexed.js | 76 + .../@sentry/core/esm/transports/offline.js | 122 + .../@sentry/core/esm/utils/hasTracingEnabled.js | 23 + .../@sentry/core/esm/utils/prepareEvent.js | 304 + .../node_modules/@sentry/core/esm/version.js | 4 + .../node_modules/@sentry/replay/esm/index.js | 8542 ++++++++++++++++++++ .../node_modules/@sentry/types/esm/severity.js | 24 + .../node_modules/@sentry/utils/esm/baggage.js | 145 + .../node_modules/@sentry/utils/esm/browser.js | 152 + .../utils/esm/buildPolyfills/_optionalChain.js | 59 + .../node_modules/@sentry/utils/esm/clientreport.js | 25 + .../logger/node_modules/@sentry/utils/esm/dsn.js | 126 + .../logger/node_modules/@sentry/utils/esm/env.js | 34 + .../node_modules/@sentry/utils/esm/envelope.js | 232 + .../logger/node_modules/@sentry/utils/esm/error.js | 17 + .../node_modules/@sentry/utils/esm/instrument.js | 631 ++ shared/logger/node_modules/@sentry/utils/esm/is.js | 179 + .../node_modules/@sentry/utils/esm/logger.js | 83 + .../logger/node_modules/@sentry/utils/esm/memo.js | 45 + .../logger/node_modules/@sentry/utils/esm/misc.js | 197 + .../logger/node_modules/@sentry/utils/esm/node.js | 66 + .../node_modules/@sentry/utils/esm/normalize.js | 263 + .../node_modules/@sentry/utils/esm/object.js | 279 + .../@sentry/utils/esm/promisebuffer.js | 102 + .../node_modules/@sentry/utils/esm/ratelimit.js | 97 + .../node_modules/@sentry/utils/esm/severity.js | 36 + .../node_modules/@sentry/utils/esm/stacktrace.js | 136 + .../node_modules/@sentry/utils/esm/string.js | 132 + .../node_modules/@sentry/utils/esm/supports.js | 161 + .../node_modules/@sentry/utils/esm/syncpromise.js | 191 + .../logger/node_modules/@sentry/utils/esm/time.js | 183 + .../node_modules/@sentry/utils/esm/tracing.js | 39 + .../logger/node_modules/@sentry/utils/esm/url.js | 72 + .../@sentry/utils/esm/vendor/supportsHistory.js | 29 + .../node_modules/@sentry/utils/esm/worldwide.js | 70 + shared/logger/src/base.ts | 67 + shared/logger/src/composite.ts | 92 + shared/logger/src/console.ts | 29 + shared/logger/src/errorkit/errorkit-logger.ts | 93 + shared/logger/src/errorkit/errorkit.ts | 108 + shared/logger/src/index.ts | 31 + shared/logger/src/local-storage-filter.ts | 122 + .../dist/ae-client-kit-core.esm.js | 901 +++ .../mt-client-config/dist/mt-client-config.esm.js | 987 +++ .../dist/mt-client-constraints.esm.js | 3103 +++++++ .../dist/mt-client-logger-core.esm.js | 533 ++ .../mt-event-queue/dist/mt-event-queue.esm.js | 1364 ++++ .../dist/mt-metricskit-delegates-core.esm.js | 289 + .../dist/mt-metricskit-delegates-web.esm.js | 728 ++ .../mt-metricskit-processor-clickstream.esm.js | 4045 +++++++++ .../dist/mt-metricskit-utils-private.esm.js | 2428 ++++++ .../@jet/engine/lib/actions/action-dispatcher.js | 64 + .../node_modules/@jet/engine/lib/actions/index.js | 13 + .../@jet/engine/lib/dependencies/index.js | 17 + .../@jet/engine/lib/dependencies/jet-bag.js | 40 + .../@jet/engine/lib/dependencies/jet-host.js | 19 + .../engine/lib/dependencies/jet-network-fetch.js | 39 + .../lib/dependencies/localized-strings-bundle.js | 68 + .../dependencies/localized-strings-json-object.js | 21 + .../node_modules/@jet/engine/lib/index.js | 15 + .../@jet/engine/lib/metrics/aggregating/index.js | 16 + .../aggregating/metrics-fields-aggregator.js | 45 + .../metrics/aggregating/metrics-fields-builder.js | 15 + .../metrics/aggregating/metrics-fields-context.js | 2 + .../metrics/aggregating/metrics-fields-provider.js | 2 + .../engine/lib/metrics/field-providers/index.js | 13 + .../page-metrics-fields-provider.js | 19 + .../node_modules/@jet/engine/lib/metrics/index.js | 18 + .../@jet/engine/lib/metrics/linting/index.js | 13 + .../lib/metrics/linting/metrics-event-linter.js | 2 + .../@jet/engine/lib/metrics/metrics-pipeline.js | 35 + .../@jet/engine/lib/metrics/presenters/index.js | 13 + .../metrics/presenters/page-metrics-presenter.js | 51 + .../@jet/engine/lib/metrics/recording/index.js | 14 + .../metrics/recording/logging-event-recorder.js | 13 + .../metrics/recording/metrics-event-recorder.js | 2 + .../engine/node_modules/@jet/environment/index.js | 19 + .../@jet/environment/json/validation.js | 250 + .../environment/models/actions/alert-action.js | 3 + .../environment/models/actions/compound-action.js | 3 + .../environment/models/actions/empty-action.js | 3 + .../models/actions/external-url-action.js | 3 + .../@jet/environment/models/actions/flow-action.js | 3 + .../environment/models/actions/flow-back-action.js | 3 + .../@jet/environment/models/actions/http-action.js | 3 + .../models/actions/http-template-action.js | 3 + .../@jet/environment/models/actions/index.js | 22 + .../environment/models/actions/toast-action.js | 3 + .../@jet/environment/models/artwork.js | 39 + .../node_modules/@jet/environment/models/button.js | 3 + .../node_modules/@jet/environment/models/color.js | 131 + .../node_modules/@jet/environment/models/index.js | 21 + .../node_modules/@jet/environment/models/menu.js | 8 + .../@jet/environment/models/paragraph.js | 4 + .../@jet/environment/models/programmed-text.js | 5 + .../node_modules/@jet/environment/models/video.js | 3 + .../@jet/environment/types/globals/bag.js | 3 + .../@jet/environment/types/globals/bundle.js | 3 + .../environment/types/globals/cookie-provider.js | 3 + .../@jet/environment/types/globals/cryptography.js | 3 + .../@jet/environment/types/globals/host.js | 3 + .../@jet/environment/types/globals/index.js | 51 + .../@jet/environment/types/globals/jscookie.js | 3 + .../@jet/environment/types/globals/net.js | 3 + .../@jet/environment/types/globals/platform.js | 3 + .../@jet/environment/types/globals/plist.js | 3 + .../@jet/environment/types/globals/preprocessor.js | 3 + .../@jet/environment/types/globals/random.js | 3 + .../@jet/environment/types/globals/service.js | 3 + .../@jet/environment/types/globals/types.js | 16 + .../environment/types/javascriptcore/console.js | 14 + .../@jet/environment/types/javascriptcore/index.js | 14 + .../node_modules/@jet/environment/types/metrics.js | 57 + .../node_modules/@jet/environment/types/models.js | 3 + .../@jet/environment/types/optional.js | 71 + .../node_modules/@jet/environment/util/metatype.js | 10 + .../node_modules/@jet/environment/util/urls.js | 370 + shared/metrics-8/src/constants.ts | 19 + shared/metrics-8/src/impression-provider.ts | 27 + .../metrics-8/src/impression-snapshot-provider.ts | 27 + shared/metrics-8/src/impressions/constants.ts | 1 + shared/metrics-8/src/impressions/index.ts | 252 + shared/metrics-8/src/index.ts | 578 ++ shared/metrics-8/src/recorder/composite.ts | 20 + shared/metrics-8/src/recorder/funnelkit.ts | 237 + shared/metrics-8/src/recorder/logging.ts | 21 + shared/metrics-8/src/recorder/metricskit.ts | 239 + shared/metrics-8/src/recorder/void.ts | 17 + .../metrics-8/src/utils/get-event-field-topic.ts | 11 + .../src/utils/metrics-dev-console/constants.ts | 7 + .../utils/metrics-dev-console/setup-metrics-dev.ts | 55 + shared/storefronts/src/index.js | 19 + .../@amp/runtime-detect/dist/extensions/compare.js | 83 + .../@amp/runtime-detect/dist/extensions/flags.js | 105 + .../node_modules/@amp/runtime-detect/dist/rules.js | 22 + .../@amp/runtime-detect/dist/user-agent.js | 392 + .../@amp/runtime-detect/dist/version.js | 99 + shared/utils/src/get-pwa-display-mode.ts | 39 + shared/utils/src/history.ts | 168 + shared/utils/src/is-pojo.ts | 20 + shared/utils/src/launch/launch-client.ts | 109 + shared/utils/src/launch/scheme.ts | 339 + shared/utils/src/lru-map.ts | 60 + shared/utils/src/object-from-entries.ts | 18 + shared/utils/src/optional.ts | 22 + shared/utils/src/platform.ts | 249 + shared/utils/src/try-scroll.ts | 65 + shared/utils/src/url.ts | 90 + shared/utils/src/uuid.ts | 22 + 312 files changed, 55320 insertions(+) create mode 100644 shared/apps-common/src/jet/dependencies/host.ts create mode 100644 shared/apps-common/src/jet/dependencies/random.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/index.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/server-data.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/types.ts create mode 100644 shared/components/assets/icons/arrow.svg create mode 100644 shared/components/assets/icons/chevron.svg create mode 100644 shared/components/assets/icons/close.svg create mode 100644 shared/components/assets/icons/search.svg create mode 100644 shared/components/assets/icons/star-filled.svg create mode 100644 shared/components/assets/icons/star-hollow.svg create mode 100644 shared/components/assets/shelf/chevron-compact-left.svg create mode 100644 shared/components/config/components/artwork.ts create mode 100644 shared/components/config/components/shelf.ts create mode 100644 shared/components/node_modules/intersection-observer-admin/dist/intersection-observer-admin.es5.js create mode 100644 shared/components/src/actions/allow-drag.ts create mode 100644 shared/components/src/actions/allow-drop.ts create mode 100644 shared/components/src/actions/click-outside.ts create mode 100644 shared/components/src/actions/focus-node-on-mount.ts create mode 100644 shared/components/src/actions/focus-node.ts create mode 100644 shared/components/src/actions/intersection-observer.ts create mode 100644 shared/components/src/actions/list-keyboard-access.ts create mode 100644 shared/components/src/actions/updateScrollAndWindowDependentVisuals.ts create mode 100644 shared/components/src/components/Artwork/Artwork.svelte create mode 100644 shared/components/src/components/Artwork/constants.ts create mode 100644 shared/components/src/components/Artwork/loaders/LazyLoader.svelte create mode 100644 shared/components/src/components/Artwork/loaders/LoaderSelector.svelte create mode 100644 shared/components/src/components/Artwork/loaders/NoLoader.svelte create mode 100644 shared/components/src/components/Artwork/stores/artworkLoader.ts create mode 100644 shared/components/src/components/Artwork/utils/artProfile.ts create mode 100644 shared/components/src/components/Artwork/utils/preconnect.ts create mode 100644 shared/components/src/components/Artwork/utils/replaceQualityParam.ts create mode 100644 shared/components/src/components/Artwork/utils/srcset.ts create mode 100644 shared/components/src/components/Artwork/utils/validateBackground.ts create mode 100644 shared/components/src/components/Error/ErrorPage.svelte create mode 100644 shared/components/src/components/Footer/Footer.svelte create mode 100644 shared/components/src/components/LineClamp/LineClamp.svelte create mode 100644 shared/components/src/components/LoadingSpinner/LoadingSpinner.svelte create mode 100644 shared/components/src/components/MetaTags/MetaTags.svelte create mode 100644 shared/components/src/components/Modal/ContentModal.svelte create mode 100644 shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherModal.svelte create mode 100644 shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegion.svelte create mode 100644 shared/components/src/components/Modal/LocaleSwitcherModal/LocaleSwitcherRegionList.svelte create mode 100644 shared/components/src/components/Modal/Modal.svelte create mode 100644 shared/components/src/components/Navigation/Folder.svelte create mode 100644 shared/components/src/components/Navigation/Item.svelte create mode 100644 shared/components/src/components/Navigation/ItemContent.svelte create mode 100644 shared/components/src/components/Navigation/MenuIcon.svelte create mode 100644 shared/components/src/components/Navigation/Navigation.svelte create mode 100644 shared/components/src/components/Navigation/NavigationItems.svelte create mode 100644 shared/components/src/components/Navigation/store/menu-state.ts create mode 100644 shared/components/src/components/Navigation/utils.ts create mode 100644 shared/components/src/components/Rating/Rating.svelte create mode 100644 shared/components/src/components/Rating/utils.ts create mode 100644 shared/components/src/components/SearchInput/SearchInput.svelte create mode 100644 shared/components/src/components/SearchSuggestions/SearchSuggestions.svelte create mode 100644 shared/components/src/components/Shelf/Nav.svelte create mode 100644 shared/components/src/components/Shelf/Shelf.svelte create mode 100644 shared/components/src/components/Shelf/ShelfItem.svelte create mode 100644 shared/components/src/components/Shelf/actions/observe.ts create mode 100644 shared/components/src/components/Shelf/constants.ts create mode 100644 shared/components/src/components/Shelf/store/visibleStore.ts create mode 100644 shared/components/src/components/Shelf/utils/getGridVars.ts create mode 100644 shared/components/src/components/Shelf/utils/getMaxVisibleItems.ts create mode 100644 shared/components/src/components/Shelf/utils/observerCallback.ts create mode 100644 shared/components/src/components/Shelf/utils/shelf-window.ts create mode 100644 shared/components/src/components/TextSearchSuggestion/TextSearchSuggestion.svelte create mode 100644 shared/components/src/components/Truncate/Truncate.svelte create mode 100644 shared/components/src/components/buttons/Button.svelte create mode 100644 shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte create mode 100644 shared/components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherLanguages.svelte create mode 100644 shared/components/src/components/helpers/ResizeDetector.svelte create mode 100644 shared/components/src/constants.ts create mode 100644 shared/components/src/stores/media-query.ts create mode 100644 shared/components/src/stores/navigation-folders-open.ts create mode 100644 shared/components/src/stores/prefers-reduced-motion.ts create mode 100644 shared/components/src/stores/sidebar-hidden.ts create mode 100644 shared/components/src/utils/cookie.ts create mode 100644 shared/components/src/utils/date.ts create mode 100644 shared/components/src/utils/debounce.ts create mode 100644 shared/components/src/utils/getMediaConditions.ts create mode 100644 shared/components/src/utils/getStorefrontRoute.ts create mode 100644 shared/components/src/utils/getUpdatedFocusedIndex.ts create mode 100644 shared/components/src/utils/internal/locale/index.ts create mode 100644 shared/components/src/utils/makeSafeTick.ts create mode 100644 shared/components/src/utils/memoize.ts create mode 100644 shared/components/src/utils/rafQueue.ts create mode 100644 shared/components/src/utils/sanitize-html/browser.ts create mode 100644 shared/components/src/utils/sanitize-html/common.ts create mode 100644 shared/components/src/utils/sanitize.ts create mode 100644 shared/components/src/utils/scrollByPolyfill.ts create mode 100644 shared/components/src/utils/shelfAspectRatio.ts create mode 100644 shared/components/src/utils/should-show-navigation-item.ts create mode 100644 shared/components/src/utils/throttle.ts create mode 100644 shared/components/src/utils/uniqueId.ts create mode 100644 shared/featurekit/src/is-enabled.ts create mode 100644 shared/fonts/src/index.ts create mode 100644 shared/localization/node_modules/make-plural/cardinals.mjs create mode 100644 shared/localization/src/getLocAttributes.ts create mode 100644 shared/localization/src/getPageDir.ts create mode 100644 shared/localization/src/i18n.ts create mode 100644 shared/localization/src/setHTMLAttributes.ts create mode 100644 shared/localization/src/translator.ts create mode 100644 shared/logger/node_modules/@amp-metrics/sentrykit/dist/index.mjs create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/backgroundtab.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/browsertracing.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/metrics/index.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/metrics/utils.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/request.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/router.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/types.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/getCLS.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/getFID.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/getLCP.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/bindReporter.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/generateUniqueID.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/getActivationStart.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/getNavigationEntry.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/getVisibilityWatcher.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/initMetric.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/observe.js create mode 100644 shared/logger/node_modules/@sentry-internal/tracing/esm/browser/web-vitals/lib/onHidden.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/client.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/eventbuilder.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/helpers.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/index.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/integrations/breadcrumbs.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/integrations/dedupe.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/integrations/globalhandlers.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/integrations/httpcontext.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/integrations/linkederrors.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/integrations/trycatch.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/profiling/hubextensions.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/profiling/integration.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/profiling/utils.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/sdk.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/stack-parsers.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/transports/fetch.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/transports/offline.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/transports/utils.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/transports/xhr.js create mode 100644 shared/logger/node_modules/@sentry/browser/esm/userfeedback.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/api.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/baseclient.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/constants.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/envelope.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/exports.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/hub.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/integration.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/integrations/functiontostring.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/integrations/inboundfilters.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/scope.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/sdk.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/session.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/errors.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/hubextensions.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/idletransaction.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/span.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/trace.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/transaction.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/tracing/utils.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/transports/base.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/transports/multiplexed.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/transports/offline.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/utils/hasTracingEnabled.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/utils/prepareEvent.js create mode 100644 shared/logger/node_modules/@sentry/core/esm/version.js create mode 100644 shared/logger/node_modules/@sentry/replay/esm/index.js create mode 100644 shared/logger/node_modules/@sentry/types/esm/severity.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/baggage.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/browser.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/buildPolyfills/_optionalChain.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/clientreport.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/dsn.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/env.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/envelope.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/error.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/instrument.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/is.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/logger.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/memo.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/misc.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/node.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/normalize.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/object.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/promisebuffer.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/ratelimit.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/severity.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/stacktrace.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/string.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/supports.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/syncpromise.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/time.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/tracing.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/url.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/vendor/supportsHistory.js create mode 100644 shared/logger/node_modules/@sentry/utils/esm/worldwide.js create mode 100644 shared/logger/src/base.ts create mode 100644 shared/logger/src/composite.ts create mode 100644 shared/logger/src/console.ts create mode 100644 shared/logger/src/errorkit/errorkit-logger.ts create mode 100644 shared/logger/src/errorkit/errorkit.ts create mode 100644 shared/logger/src/index.ts create mode 100644 shared/logger/src/local-storage-filter.ts create mode 100644 shared/metrics-8/node_modules/@amp-metrics/ae-client-kit-core/dist/ae-client-kit-core.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-client-config/dist/mt-client-config.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-client-constraints/dist/mt-client-constraints.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-client-logger-core/dist/mt-client-logger-core.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-event-queue/dist/mt-event-queue.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-metricskit-delegates-core/dist/mt-metricskit-delegates-core.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-metricskit-delegates-web/dist/mt-metricskit-delegates-web.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-metricskit-processor-clickstream/dist/mt-metricskit-processor-clickstream.esm.js create mode 100644 shared/metrics-8/node_modules/@amp-metrics/mt-metricskit-utils-private/dist/mt-metricskit-utils-private.esm.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/actions/action-dispatcher.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/actions/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/dependencies/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/dependencies/jet-bag.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/dependencies/jet-host.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/dependencies/jet-network-fetch.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/dependencies/localized-strings-bundle.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/dependencies/localized-strings-json-object.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/aggregating/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/aggregating/metrics-fields-aggregator.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/aggregating/metrics-fields-builder.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/aggregating/metrics-fields-context.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/aggregating/metrics-fields-provider.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/field-providers/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/field-providers/page-metrics-fields-provider.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/linting/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/linting/metrics-event-linter.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/metrics-pipeline.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/presenters/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/presenters/page-metrics-presenter.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/recording/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/recording/logging-event-recorder.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/lib/metrics/recording/metrics-event-recorder.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/json/validation.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/alert-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/compound-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/empty-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/external-url-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/flow-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/flow-back-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/http-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/http-template-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/actions/toast-action.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/artwork.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/button.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/color.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/menu.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/paragraph.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/programmed-text.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/models/video.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/bag.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/bundle.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/cookie-provider.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/cryptography.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/host.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/jscookie.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/net.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/platform.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/plist.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/preprocessor.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/random.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/service.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/globals/types.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/javascriptcore/console.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/javascriptcore/index.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/metrics.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/models.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/types/optional.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/util/metatype.js create mode 100644 shared/metrics-8/node_modules/@jet/engine/node_modules/@jet/environment/util/urls.js create mode 100644 shared/metrics-8/src/constants.ts create mode 100644 shared/metrics-8/src/impression-provider.ts create mode 100644 shared/metrics-8/src/impression-snapshot-provider.ts create mode 100644 shared/metrics-8/src/impressions/constants.ts create mode 100644 shared/metrics-8/src/impressions/index.ts create mode 100644 shared/metrics-8/src/index.ts create mode 100644 shared/metrics-8/src/recorder/composite.ts create mode 100644 shared/metrics-8/src/recorder/funnelkit.ts create mode 100644 shared/metrics-8/src/recorder/logging.ts create mode 100644 shared/metrics-8/src/recorder/metricskit.ts create mode 100644 shared/metrics-8/src/recorder/void.ts create mode 100644 shared/metrics-8/src/utils/get-event-field-topic.ts create mode 100644 shared/metrics-8/src/utils/metrics-dev-console/constants.ts create mode 100644 shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts create mode 100644 shared/storefronts/src/index.js create mode 100644 shared/utils/node_modules/@amp/runtime-detect/dist/extensions/compare.js create mode 100644 shared/utils/node_modules/@amp/runtime-detect/dist/extensions/flags.js create mode 100644 shared/utils/node_modules/@amp/runtime-detect/dist/rules.js create mode 100644 shared/utils/node_modules/@amp/runtime-detect/dist/user-agent.js create mode 100644 shared/utils/node_modules/@amp/runtime-detect/dist/version.js create mode 100644 shared/utils/src/get-pwa-display-mode.ts create mode 100644 shared/utils/src/history.ts create mode 100644 shared/utils/src/is-pojo.ts create mode 100644 shared/utils/src/launch/launch-client.ts create mode 100644 shared/utils/src/launch/scheme.ts create mode 100644 shared/utils/src/lru-map.ts create mode 100644 shared/utils/src/object-from-entries.ts create mode 100644 shared/utils/src/optional.ts create mode 100644 shared/utils/src/platform.ts create mode 100644 shared/utils/src/try-scroll.ts create mode 100644 shared/utils/src/url.ts create mode 100644 shared/utils/src/uuid.ts (limited to 'shared') diff --git a/shared/apps-common/src/jet/dependencies/host.ts b/shared/apps-common/src/jet/dependencies/host.ts new file mode 100644 index 0000000..85a03f0 --- /dev/null +++ b/shared/apps-common/src/jet/dependencies/host.ts @@ -0,0 +1,57 @@ +import type { + ClientIdentifier, + Host as NativeHost, + ProcessPlatform, +} from '@jet/environment'; +import type {} from '@jet/engine'; // For ClientIdentifier.Unknown + +export class Host implements NativeHost { + platform: ProcessPlatform = 'web'; + + get osBuild(): never { + throw makeWebDoesNotImplementException('osBuild'); + } + + get deviceModel(): string { + return 'web'; + } + + get devicePhysicalModel(): never { + throw makeWebDoesNotImplementException('devicePhysicalModel'); + } + + get deviceLocalizedModel() { + return ''; + } + + get deviceModelFamily(): never { + throw makeWebDoesNotImplementException('deviceModelFamily'); + } + + get clientIdentifier(): ClientIdentifier { + // We can't directly use the `ClientIdentifier.Unknown` enum member value + // because we cannot access "ambient const enums" with our TypeScript config. + // Enum handling is known to be tough in TypeScript and, for reasons like + // this, they are generally avoided. + // This returns a value defined on this enum by `@jet/engine`'s type definition + return 'unknown' as ClientIdentifier.Unknown; + } + + get clientVersion(): never { + throw makeWebDoesNotImplementException('clientVersion'); + } + + isOSAtLeast( + _majorVersion: number, + _minorVersion: number, + _patchVersion: number, + ): boolean { + return true; + } +} + +export function makeWebDoesNotImplementException(property: keyof NativeHost) { + return new Error( + `\`Host\` property \`${property}\` is not implemented for the "web" platform`, + ); +} diff --git a/shared/apps-common/src/jet/dependencies/random.ts b/shared/apps-common/src/jet/dependencies/random.ts new file mode 100644 index 0000000..d976879 --- /dev/null +++ b/shared/apps-common/src/jet/dependencies/random.ts @@ -0,0 +1,18 @@ +import type { Random as IRandom } from '@jet/environment'; +import { generateUuid } from '@amp/web-apps-utils'; + +export class Random implements IRandom { + nextBoolean(): boolean { + // See: https://stashweb.sd.apple.com/projects/AS/repos/jet-infrastructure/browse/Frameworks/JetEngine/JetEngine/JavaScript/Stack/Native%20APIs/JSRandomObject.swift?at=e90a88fa061f5cb6b9536d29a7ffd67e5db942db#41 + return Math.random() < 0.5; + } + + nextNumber(): number { + // See: https://stashweb.sd.apple.com/projects/AS/repos/jet-infrastructure/browse/Frameworks/JetEngine/JetEngine/JavaScript/Stack/Native%20APIs/JSRandomObject.swift?at=e90a88fa061f5cb6b9536d29a7ffd67e5db942db#45 + return Math.random(); + } + + nextUUID(): string { + return generateUuid(); + } +} diff --git a/shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts b/shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts new file mode 100644 index 0000000..4d59186 --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts @@ -0,0 +1,58 @@ +import { getCookie } from '@amp/web-app-components/src/utils/cookie'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import { isSome } from '@amp/web-apps-utils'; +import { deserializeServerData, stableStringify } from './server-data'; +import { type PrefetchedIntent, isPrefetchedIntents } from './types'; + +export function getPrefetchedIntents( + loggerFactory: LoggerFactory, + options?: { evenIfSignedIn?: boolean; featureKitItfe?: string }, +): Map { + const logger = loggerFactory.loggerFor('getPrefetchedIntents'); + const evenIfSignedIn = options?.evenIfSignedIn; + const itfe = options?.featureKitItfe; + + const data = deserializeServerData(); + if (!data || !isPrefetchedIntents(data)) { + return new Map(); + } + + // We avoid prefetched intents in two scenarios: + // + // Condition 1: User is signed in (and evenIfSignedIn is false) + // It's possible/likely that dispatching an intent when signed in behaves + // differently. + // + // Condition 2: ITFE is enabled in Feature Kit + // When ITFE is active, we discard prefetched intents so that media API + // calls are triggered in the browser, allowing Feature Kit to inject ITFE + // into those calls. + if ((!evenIfSignedIn && getCookie('media-user-token')) || itfe) { + logger.info( + 'Discarding prefetched intents - signed in user or ITFE enabled', + ); + return new Map(); + } + + logger.debug('received prefetched intents from the server:', data); + return new Map( + data + .map( + ({ + intent, + data, + }: PrefetchedIntent): [string, unknown] | null => { + try { + if (intent.$kind.includes('Library')) { + return null; + } + // NOTE: PrefetchedIntents.get depends on stableStringify + return [stableStringify(intent), data]; + } catch (e) { + return null; + } + }, + ) + .filter(isSome), + ); +} diff --git a/shared/apps-common/src/jet/prefetched-intents/index.ts b/shared/apps-common/src/jet/prefetched-intents/index.ts new file mode 100644 index 0000000..dd5d393 --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/index.ts @@ -0,0 +1,118 @@ +import type { LoggerFactory } from '@amp/web-apps-logger'; +import type { Intent, IntentReturnType } from '@jet/environment/dispatching'; +import { serializeServerData, stableStringify } from './server-data'; +import type { PrefetchedIntent } from './types'; +import { getPrefetchedIntents } from './get-prefetched-intents'; + +export type { PrefetchedIntent } from './types'; + +export function serializePrefetchedIntents( + loggerFactory: LoggerFactory, + prefetchedIntents: PrefetchedIntent[], +): string { + const serialized = serializeServerData( + prefetchedIntents.map(removeSeoData), + ); + + if (serialized.length === 0) { + const logger = loggerFactory.loggerFor('serializePrefetchedIntents'); + logger.warn('failed to serialize prefetched intents'); + } + + return serialized; +} + +// SEO data is never needed for the first clientside render since the server +// already adds SEO tags. The seoData convention is ubiquitous across the apps. +// See: rdar://144581413 (Etag constantly changes on pages with songs due to seoData.ogSongs) +function removeSeoData(intent: PrefetchedIntent): PrefetchedIntent { + const { data } = intent; + + // We very intentionally return the original intent to prevent + // needlessly allocating new objects. + + if (data === null || typeof data !== 'object' || !('seoData' in data)) { + return intent; + } + + const { seoData } = data; + if (seoData === null || typeof seoData !== 'object') { + return intent; + } + + let partialSeoData: + | { pageTitle?: unknown; titleHeader?: unknown } + | undefined = undefined; + if ('pageTitle' in seoData || 'titleHeader' in seoData) { + partialSeoData = {}; + + if ('pageTitle' in seoData) { + partialSeoData['pageTitle'] = seoData.pageTitle; + } + + if ('titleHeader' in seoData) { + partialSeoData['titleHeader'] = seoData.titleHeader; + } + } + + // Only if we're actually going to do the removal do we spread + return { + ...intent, + data: { + ...data, + // Page title is desirable to keep as it is occasionally consulted + // outside of MetaTags.svelte + seoData: partialSeoData, + }, + }; +} + +export class PrefetchedIntents { + static empty(): PrefetchedIntents { + return new PrefetchedIntents(new Map()); + } + + static fromDom( + loggerFactory: LoggerFactory, + options?: { evenIfSignedIn?: boolean; featureKitItfe?: string }, + ): PrefetchedIntents { + return new PrefetchedIntents( + getPrefetchedIntents(loggerFactory, options), + ); + } + + private intents: Map; + + private constructor(intents: Map) { + this.intents = intents; + } + + get>(intent: I): IntentReturnType | undefined { + if (this.intents.size === 0) { + return; + } + + let subject: string | void; + try { + subject = stableStringify(intent); + } catch (e) { + // It's possible the intents don't stringify. If that's that case, + // then we won't find it in this.intents, since the keys of that + // are successfully stringified intents. We could try something + // sophisticated here, but it's probably not worth it as most + // intents will serialize. + return; + } + + const data = this.intents.get(subject); + + // Remove the prefetched data so that it can only be used once + this.intents.delete(subject); + + // NOTE: There really isn't a good way to be safe with types here. We + // don't have a type guard for arbitrary IntentReturnType. We just + // have to trust that the serialized data is of the correct type. This + // isn't unreasonable since we control serialization. + return data as unknown as IntentReturnType | undefined; + } +} diff --git a/shared/apps-common/src/jet/prefetched-intents/server-data.ts b/shared/apps-common/src/jet/prefetched-intents/server-data.ts new file mode 100644 index 0000000..fba215c --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/server-data.ts @@ -0,0 +1,109 @@ +import { isPOJO } from '@amp/web-apps-utils'; + +// NOTE: be careful with imports here. This file is imported by browser code, +// so we expect tree shaking to only keep these functions. + +const SERVER_DATA_ID = 'serialized-server-data'; + +// Take care with < (which has special meaning inside script tags) +// See: https://github.com/sveltejs/kit/blob/ff9a27b4/packages/kit/src/runtime/server/page/serialize_data.js#L4-L28 +const replacements = { + '<': '\\u003C', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g'); + +/** + * Serializes a POJO into a HTML `; + } catch (e) { + // Don't let recursive data (or other non-serializable things) throw. + // We'd rather just let the serialize no-op to avoid breaking consumers. + return ''; + } +} + +/** + * Deserializes data serialized on the server by `serializeServerData`. + * + * @returns deserialized data (or undefined if it doesn't exist/errors) + */ +export function deserializeServerData(): ReturnType | undefined { + const script = document.getElementById(SERVER_DATA_ID); + if (!script) { + return; + } + + script.parentNode?.removeChild(script); + + try { + return JSON.parse(script.textContent || ''); + } catch (e) { + // If the content is malformed, we want to avoid throwing. This + // situation should be impossible since we control the serialization + // above. + return; + } +} + +/** + * JSON stringify a POJO value in a stable manner. Specifically, this means that + * objects which are structurally equal serialize to the same string. + * + * This is useful when comparing objects serialized by a server against objects + * build in browser. With plain JSON.stringify(), property order matters and is + * not guaranteed to be the same. In other words these two objects would + * JSON.stringify() differently: + * + * { a: 1, b: 2 } + * { b: 2, a: 1 } + * + * But these are structurally equal--they have the same keys and values. + * + * The expected use case for this function is generating keys for a Map for + * objects from a server that will be compared against objects from the browser. + * This function should be used on objects returned from `deserializeServerData` + * before they are used in such contexts. + * + * See: https://stackoverflow.com/a/43049877 + */ +export function stableStringify(data: unknown): string { + if (Array.isArray(data)) { + const items = data.map(stableStringify).join(','); + return `[${items}]`; + } + + // Sort object keys before serializing + if (isPOJO(data)) { + const keys = [...Object.keys(data)]; + keys.sort(); + + const properties = keys + // undefined values should not get included in stringification + .filter((key) => typeof data[key] !== 'undefined') + .map( + (key) => `${JSON.stringify(key)}:${stableStringify(data[key])}`, + ) + .join(','); + + return `{${properties}}`; + } + + return JSON.stringify(data); +} diff --git a/shared/apps-common/src/jet/prefetched-intents/types.ts b/shared/apps-common/src/jet/prefetched-intents/types.ts new file mode 100644 index 0000000..b44a14b --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/types.ts @@ -0,0 +1,27 @@ +import type { Intent } from '@jet/environment/dispatching'; + +export interface PrefetchedIntent { + intent: Intent; + data: unknown; +} + +export function isPrefetchedIntents(v: unknown): v is PrefetchedIntent[] { + return Array.isArray(v) && v.every(isPrefetchedIntent); +} + +function isPrefetchedIntent(v: unknown): v is PrefetchedIntent { + return hasIntentAndData(v) && isIntent(v.intent); +} + +function hasIntentAndData(v: unknown): v is HasIntentAndData { + return v !== null && typeof v === 'object' && 'intent' in v && 'data' in v; +} + +interface HasIntentAndData { + intent: unknown; + data: unknown; +} + +function isIntent(v: unknown): v is Intent { + return v !== null && typeof v === 'object' && '$kind' in v; +} 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 = 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>(null); +export const activeDragHandler: Readable> = { 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 based on the passed in dragData + dragImage?: HTMLElement | string; + usePlainDragImage?: boolean; + isContainer?: boolean; + badgeCount?: number; + effectAllowed?: DataTransfer['effectAllowed']; +}; + +class DragHandler { + 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 & { 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 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 ( 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: + *
+ */ +export function allowDrag( + target: HTMLElement, + options: DragOptions | false, +): ActionReturn { + 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: + *
+ */ +export function allowDrop( + target: HTMLElement, + options: DropOptions, +): ActionReturn { + 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 `
` + * @example `
` + * @example `
` + * @example `
` + */ +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; + 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 { + 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([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, +) { + 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 = + 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, +): 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 @@ + + +
+ {#if imageIsLoading && $$slots['loading-component']} +
+ +
+ {:else if thereWasAnError && $$slots['placeholder-component']} +
+ +
+ {/if} + + {#if !thereWasAnError && isVisible} + + {#if webpSourceSet} + + {/if} + + + + {/if} + +
+ + 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 @@ + + + + + + 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 @@ + + + + +{#if loaderType === LOADER_TYPE.LAZY && shouldUseLazyLoader} + +{:else} + +{/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 @@ + + + + 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>['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 { + 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 and . + * This keeps track of the origins of rendered assets to generate the + * appropriate tags. + * + * Preconnect tags should be rendered by placing a at the + * bottom of the top level component. + */ +export class PreconnectTracker { + private readonly originsSet: Set; + + /** + * 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): 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 `` and ``. + */ + 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 and + * 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, + 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, ArtworkMaxSizes] { + const { width, height, template } = artwork; + const chinHeight = chinConfig?.height ?? 0; + + const urlParams: Partial = { + 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, + profile: Profile | string, + artworkSizes: ArtworkMaxSizes, + options: ImageSettings, + chinConfig?: ChinConfig, +): string { + const urlParamsArray = deriveUrlParamsArray( + urlParams, + profile, + artworkSizes.maxWidth, + ); + const DEFAULT_OPTIONS: Partial = { + 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 @@ + + + +
+

+ {translateFn(locKey)} +

+ + {#if isRetryError(error)} + + {/if} +
+ + 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 @@ + + + + + + + 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 @@ + + + + + +
+ + {#if $$slots.badge && shouldRenderBadgeSlots}{/if} +
+ + 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 @@ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + 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 @@ + + + + {#if pageTitle} + + {directionMarker}{pageTitle} + {/if} + + {#if !!seoData} + + + {#if seoData.noFollow} + + + {:else if seoData.noIndex} + + + {/if} + + {#if seoData.description} + + {/if} + + {#if seoData.keywords} + + {/if} + + {#if canonicalUrl} + + {/if} + + {#if hreflangTags} + {#each hreflangTags as langTag} + {#if langTag} + + {/if} + {/each} + {/if} + + + {#if !!seoData.oembedData?.url} + + {/if} + + + {#if seoData.appleStoreId} + + {/if} + + {#if seoData.appleStoreName} + + {/if} + + {#if seoData.appleContentId} + + {/if} + + {#if seoData.appleTitle} + + {/if} + + {#if seoData.appleDescription} + + {/if} + + + + {#if seoData.socialTitle} + + {/if} + + {#if seoData.socialDescription} + + {/if} + + {#if seoData.siteName} + + {/if} + + {#if seoData.url} + + {/if} + + {#if ogImageUrl} + + + + {#if seoData.imageAltTitle} + + {:else if seoData.socialTitle} + + {/if} + + {#if seoData.width} + + {/if} + + {#if seoData.height} + + {/if} + + {#if seoData.fileType} + + {/if} + {/if} + + {#if seoData.ogType} + + {/if} + + {#if seoData.socialTitle && formattedLocale} + + {/if} + + {#if $$slots['extendedOpenGraphData']} + + {/if} + + + + {#if seoData.socialTitle} + + {/if} + + {#if seoData.socialDescription} + + {/if} + + {#if seoData.twitterSite} + + {/if} + + {#if twitterImageUrl} + + + {#if seoData.imageAltTitle} + + {:else if seoData.socialTitle} + + {/if} + {/if} + + {#if seoData.twitterCardType} + + {/if} + + + + {#if $$slots['schemaOrganizationData']} + + {/if} + + {#if seoData.schemaName && sanitizedSchemaContent} + {@html ` + + `} + {/if} + + + + {#if seoData.breadcrumbSchemaName && sanitizedBreadcrumbSchemaContent} + {@html ` + + `} + {/if} + + {/if} + 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 @@ + + +
+
+ + {#if $$slots['button-container']} + + {/if} +
+ {#if title || subtitle} +
+ {#if title} +

+ {title} +

+ {/if} + {#if subtitle} +

+ {subtitle} +

+ {/if} +
+ {/if} + {#if text || $$slots['content']} +
{ + contentIsScrolling = e.detail.contentIsScrolling; + hideGradient = e.detail.hideGradient; + }} + > + {#if $$slots['content']} + + {:else} +

+ {@html sanitizeHtml(text)} +

+ {/if} +
+ {/if} +
+ + 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 @@ + + +
+ +
+ + {translateFn('AMP.Shared.LocaleSwitcher.Heading')} + +
+
+ (contentIsScrolling = e.detail.contentIsScrolling)} + > + {#if showDefaultList} + {#each regionsDefaultList as region (region.name)} + + + + + + {/each} + {:else} + + + + + + {/if} +
+
+ + 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 @@ + + +
+

+ {regionName} +

+ +
+ + + 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 @@ + + + + + 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 @@ + + + + + + + + + + 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 @@ + + + + + + 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 @@ + + + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + +
+ {#if header} + + {/if} + + +
+ + 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 @@ + + +
+
+
+ {averageRating} +
+
+ {totalText} +
+
+
+
+ {#each ratingPercentList as value, i} +
+ +
+ + {#each { length: 5 - i } as _} +
+ {/each} +
+
+
+
+
+ {/each} +
+
+ {ratingCountText} +
+
+
+ + 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 @@ + + +
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} +> +
+
+ +
+ + + {#if !hideSuggestions && suggestions && suggestions.length > 0} + {#if $$slots['suggestion']} + + onSearchSuggestionChosen(e.detail.suggestion)} + on:suggestionFocused={(e) => + onSearchSuggestionFocused(e.detail.index)} + {suggestions} + focusedSuggestionIndex={focusedSearchSuggestionIndex} + {translateFn} + > + + + + + {:else} + + onSearchSuggestionChosen(e.detail.suggestion)} + on:suggestionFocused={(e) => + onSearchSuggestionFocused(e.detail.index)} + {suggestions} + focusedSuggestionIndex={focusedSearchSuggestionIndex} + {translateFn} + /> + {/if} + {/if} +
+ + 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 @@ + + +
    + {#each suggestions as suggestion, index} + +
  • handleSuggestionClicked(suggestion)} + on:keyup|self={(e) => handleSuggestionKeyUp(suggestion, e)} + on:focusin|self={() => handleSuggestionFocused(suggestion, index)} + > + {#if $$slots['suggestion']} + + {:else} + + {/if} +
  • + {/each} +
+ +
+ + 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 @@ + + +{#if hasNavArrows} + + + +{:else} + +{/if} + + 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 @@ + + +
+ {#if $$slots.header} +
+ +
+ {/if} +
+ + + + +
+
+ + 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 @@ + + + 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 { + 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, + gridRowGap: Partial, +): 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 = 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 @@ + + +