From 3c9bf5eabf5f0b59ca7b339676d8c1ac597e375a Mon Sep 17 00:00:00 2001 From: Cory Yeakel Date: Tue, 17 Feb 2026 14:17:36 -0800 Subject: [PATCH 1/8] feat: copy Horizon features into Skeleton base Copied from Shopify Horizon commit a6a3484ce86dea3810290bad9c475847ba504c86 Skeleton base commit 04069e0feda9f7f8bda8df65ca5a22791c61c997 Copied: - 94 blocks, 52 sections, 116 assets, 95 snippets - Templates, config, locales, layout (Horizon replacements) - Dual license attribution (LICENSE-SKELETON.md, LICENSE-HORIZON.md) - Architecture and execution plans (.planning/) - Source provenance (HORIZON_VERSION.md) Zero modifications - vanilla copy for v1.0.0. --- .planning/ARCHITECTURE.md | 2272 ++++++++ .planning/EXECUTION-PLAN.md | 768 +++ HORIZON_VERSION.md | 25 + LICENSE-HORIZON.md | 13 + LICENSE.md => LICENSE-SKELETON.md | 0 assets/accordion-custom.js | 107 + assets/account-login-actions.js | 37 + assets/anchored-popover.js | 132 + assets/announcement-bar.js | 130 + assets/auto-close-details.js | 15 + assets/base.css | 4946 +++++++++++++++++ assets/blog-posts-list.js | 10 + assets/cart-discount.js | 203 + assets/cart-drawer.js | 74 + assets/cart-icon.js | 134 + assets/cart-note.js | 46 + assets/collection-links.js | 232 + assets/comparison-slider.js | 157 + assets/component-cart-items.js | 333 ++ assets/component-cart-quantity-selector.js | 38 + assets/component-quantity-selector.js | 297 + assets/component.js | 348 ++ assets/copy-to-clipboard.js | 26 + assets/dialog.js | 193 + assets/drag-zoom-wrapper.js | 503 ++ assets/events.js | 290 + assets/facets.js | 840 +++ assets/floating-panel.js | 63 + assets/fly-to-cart.js | 80 + assets/focus.js | 104 + assets/gift-card-recipient-form.js | 414 ++ assets/global.d.ts | 73 + assets/header-drawer.js | 188 + assets/header-menu.js | 241 + assets/header.js | 274 + assets/icon-account.svg | 7 +- assets/icon-add-to-cart.svg | 1 + assets/icon-arrow.svg | 1 + assets/icon-available.svg | 3 + assets/icon-caret.svg | 3 + assets/icon-cart.svg | 6 +- assets/icon-checkmark-burst.svg | 32 + assets/icon-checkmark.svg | 3 + assets/icon-chevron-left.svg | 3 + assets/icon-chevron-right.svg | 3 + assets/icon-close.svg | 4 + assets/icon-delete.svg | 4 + assets/icon-discount.svg | 4 + assets/icon-double-chevron.svg | 3 + assets/icon-error.svg | 6 + assets/icon-external.svg | 1 + assets/icon-filter.svg | 1 + assets/icon-filters-close.svg | 6 + assets/icon-grid-default.svg | 6 + assets/icon-grid-dense.svg | 11 + assets/icon-info.svg | 5 + assets/icon-inventory.svg | 5 + assets/icon-menu.svg | 4 + assets/icon-minus.svg | 3 + assets/icon-one-col-mobile.svg | 3 + assets/icon-orders.svg | 4 + assets/icon-pause.svg | 4 + assets/icon-play.svg | 3 + assets/icon-plus.svg | 4 + assets/icon-reset.svg | 1 + assets/icon-search.svg | 1 + assets/icon-shopify.svg | 1 + assets/icon-unavailable.svg | 3 + assets/jsconfig.json | 14 + assets/jumbo-text.js | 193 + assets/layered-slideshow.js | 602 ++ assets/local-pickup.js | 79 + assets/localization.js | 552 ++ assets/marquee.js | 276 + assets/media-gallery.js | 95 + assets/media.js | 248 + assets/money-formatting.js | 206 + assets/morph.js | 578 ++ assets/overflow-list.css | 66 + assets/overflow-list.js | 386 ++ assets/paginated-list-aspect-ratio.js | 171 + assets/paginated-list.js | 355 ++ assets/performance.js | 62 + assets/popover-polyfill.js | 785 +++ assets/predictive-search.js | 430 ++ assets/price-per-item.js | 134 + assets/product-card-link.js | 78 + assets/product-card.js | 535 ++ assets/product-custom-property.js | 33 + assets/product-form.js | 639 +++ assets/product-hotspot.js | 338 ++ assets/product-inventory.js | 39 + assets/product-price.js | 74 + assets/product-recommendations.js | 159 + assets/product-sku.js | 67 + assets/product-title-truncation.js | 86 + assets/qr-code-generator.js | 1663 ++++++ assets/qr-code-image.js | 35 + assets/quick-add.js | 342 ++ assets/quick-order-list.js | 536 ++ assets/recently-viewed-products.js | 35 + assets/results-list.js | 78 + assets/rte-formatter.js | 29 + assets/scrolling.js | 422 ++ assets/search-page-input.js | 49 + assets/section-hydration.js | 35 + assets/section-renderer.js | 186 + assets/show-more.js | 162 + assets/slideshow.js | 943 ++++ assets/sticky-add-to-cart.js | 359 ++ assets/template-giftcard.css | 147 + assets/theme-editor.js | 391 ++ assets/utilities.js | 794 +++ assets/variant-picker.js | 488 ++ assets/video-background.js | 32 + assets/view-transitions.js | 105 + assets/volume-pricing-info.js | 69 + assets/volume-pricing.js | 20 + assets/zoom-dialog.js | 288 + blocks/_accordion-row.liquid | 354 ++ blocks/_announcement.liquid | 322 ++ blocks/_blog-post-card.liquid | 105 + blocks/_blog-post-content.liquid | 24 + blocks/_blog-post-description.liquid | 369 ++ blocks/_blog-post-featured-image.liquid | 296 + blocks/_blog-post-image.liquid | 132 + blocks/_blog-post-info-text.liquid | 160 + blocks/_card.liquid | 532 ++ blocks/_carousel-content.liquid | 262 + blocks/_cart-products.liquid | 107 + blocks/_cart-summary.liquid | 217 + blocks/_cart-title.liquid | 185 + blocks/_collection-card-image.liquid | 157 + blocks/_collection-card.liquid | 186 + blocks/_collection-image.liquid | 168 + blocks/_collection-info.liquid | 166 + blocks/_collection-link.liquid | 190 + blocks/_content-without-appearance.liquid | 115 + blocks/_content.liquid | 160 + blocks/_divider.liquid | 77 + blocks/_featured-blog-posts-card.liquid | 300 + blocks/_featured-blog-posts-image.liquid | 114 + blocks/_featured-blog-posts-title.liquid | 412 ++ blocks/_featured-product-gallery.liquid | 79 + ...atured-product-information-carousel.liquid | 264 + blocks/_featured-product-price.liquid | 109 + blocks/_featured-product.liquid | 48 + blocks/_footer-social-icons.liquid | 32 + blocks/_header-logo.liquid | 206 + blocks/_header-menu.liquid | 924 +++ blocks/_heading.liquid | 336 ++ blocks/_hotspot-product.liquid | 114 + blocks/_image.liquid | 169 + blocks/_inline-collection-title.liquid | 165 + blocks/_inline-text.liquid | 162 + blocks/_layered-slide.liquid | 428 ++ blocks/_marquee.liquid | 214 + blocks/_media-without-appearance.liquid | 104 + blocks/_media.liquid | 134 + blocks/_product-card-gallery.liquid | 227 + blocks/_product-card-group.liquid | 522 ++ blocks/_product-card.liquid | 178 + blocks/_product-details.liquid | 710 +++ blocks/_product-list-button.liquid | 119 + blocks/_product-list-content.liquid | 454 ++ blocks/_product-list-text.liquid | 397 ++ blocks/_product-media-gallery.liquid | 327 ++ blocks/_search-input.liquid | 247 + blocks/_slide.liquid | 512 ++ blocks/_social-link.liquid | 114 + blocks/accelerated-checkout.liquid | 68 + blocks/accordion.liquid | 311 ++ blocks/add-to-cart.liquid | 51 + blocks/button.liquid | 114 + blocks/buy-buttons.liquid | 770 +++ blocks/collection-card.liquid | 210 + blocks/collection-title.liquid | 397 ++ blocks/comparison-slider.liquid | 766 +++ blocks/contact-form-submit-button.liquid | 105 + blocks/contact-form.liquid | 295 + blocks/custom-liquid.liquid | 24 + blocks/email-signup.liquid | 705 +++ blocks/featured-collection.liquid | 29 + blocks/filters.liquid | 1484 +++++ blocks/follow-on-shop.liquid | 72 + blocks/footer-copyright.liquid | 87 + blocks/footer-policy-list.liquid | 204 + blocks/group.liquid | 560 +- blocks/icon.liquid | 325 ++ blocks/image.liquid | 330 ++ blocks/jumbo-text.liquid | 135 + blocks/logo.liquid | 290 + blocks/menu.liquid | 343 ++ blocks/page-content.liquid | 10 + blocks/page.liquid | 130 + blocks/payment-icons.liquid | 142 + blocks/popup-link.liquid | 255 + blocks/price.liquid | 453 ++ blocks/product-card.liquid | 248 + blocks/product-custom-property.liquid | 345 ++ blocks/product-description.liquid | 402 ++ blocks/product-inventory.liquid | 183 + blocks/product-recommendations.liquid | 516 ++ blocks/product-title.liquid | 403 ++ blocks/quantity.liquid | 8 + blocks/review.liquid | 286 + blocks/sku.liquid | 370 ++ blocks/social-links.liquid | 240 + blocks/spacer.liquid | 184 + blocks/swatches.liquid | 157 + blocks/text.liquid | 422 +- blocks/variant-picker.liquid | 105 + blocks/video.liquid | 254 + config/settings_data.json | 628 ++- config/settings_schema.json | 2272 +++++++- layout/theme.liquid | 135 +- locales/bg.json | 332 ++ locales/cs.json | 346 ++ locales/cs.schema.json | 1110 ++++ locales/da.json | 332 ++ locales/da.schema.json | 1110 ++++ locales/de.json | 332 ++ locales/de.schema.json | 1110 ++++ locales/el.json | 332 ++ locales/en.default.json | 393 +- locales/en.default.schema.json | 1236 +++- locales/es.json | 339 ++ locales/es.schema.json | 1110 ++++ locales/fi.json | 332 ++ locales/fi.schema.json | 1110 ++++ locales/fr.json | 339 ++ locales/fr.schema.json | 1110 ++++ locales/hr.json | 339 ++ locales/hu.json | 332 ++ locales/id.json | 332 ++ locales/it.json | 339 ++ locales/it.schema.json | 1110 ++++ locales/ja.json | 332 ++ locales/ja.schema.json | 1110 ++++ locales/ko.json | 332 ++ locales/ko.schema.json | 1110 ++++ locales/lt.json | 346 ++ locales/nb.json | 332 ++ locales/nb.schema.json | 1110 ++++ locales/nl.json | 332 ++ locales/nl.schema.json | 1110 ++++ locales/pl.json | 346 ++ locales/pl.schema.json | 1110 ++++ locales/pt-BR.json | 339 ++ locales/pt-BR.schema.json | 1110 ++++ locales/pt-PT.json | 339 ++ locales/pt-PT.schema.json | 1110 ++++ locales/ro.json | 339 ++ locales/ru.json | 346 ++ locales/sk.json | 346 ++ locales/sl.json | 346 ++ locales/sv.json | 332 ++ locales/sv.schema.json | 1110 ++++ locales/th.json | 332 ++ locales/th.schema.json | 1110 ++++ locales/tr.json | 332 ++ locales/tr.schema.json | 1110 ++++ locales/vi.json | 332 ++ locales/zh-CN.json | 332 ++ locales/zh-CN.schema.json | 1110 ++++ locales/zh-TW.json | 332 ++ locales/zh-TW.schema.json | 1110 ++++ sections/_blocks.liquid | 425 ++ sections/carousel.liquid | 355 ++ sections/collection-links.liquid | 372 ++ sections/collection-list.liquid | 887 +++ sections/custom-liquid.liquid | 73 + sections/divider.liquid | 131 + sections/featured-blog-posts.liquid | 494 ++ sections/featured-product-information.liquid | 231 + sections/featured-product.liquid | 205 + sections/footer-group.json | 169 +- sections/footer-utilities.liquid | 196 + sections/footer.liquid | 269 +- sections/header-announcements.liquid | 218 + sections/header-group.json | 106 +- sections/header.liquid | 1445 ++++- sections/hero.liquid | 1497 +++++ sections/layered-slideshow.liquid | 540 ++ sections/logo.liquid | 337 ++ sections/main-404.liquid | 186 + sections/main-blog-post.liquid | 182 + sections/main-blog.liquid | 224 + sections/main-cart.liquid | 238 + sections/main-collection-list.liquid | 323 ++ sections/main-collection.liquid | 267 + sections/main-page.liquid | 94 + sections/marquee.liquid | 193 + sections/media-with-content.liquid | 432 ++ sections/password-footer.liquid | 102 + sections/password.liquid | 491 +- sections/predictive-search-empty.liquid | 13 + sections/predictive-search.liquid | 645 +++ sections/product-hotspots.liquid | 726 +++ sections/product-information.liquid | 471 ++ sections/product-list.liquid | 882 +++ sections/product-recommendations.liquid | 541 ++ sections/quick-order-list.liquid | 1180 ++++ sections/search-header.liquid | 81 + sections/search-results.liquid | 244 + .../section-rendering-product-card.liquid | 81 + sections/section.liquid | 1763 ++++++ sections/slideshow.liquid | 575 ++ snippets/account-actions.liquid | 223 + snippets/account-button.liquid | 92 + snippets/add-to-cart-button.liquid | 68 + snippets/background-media.liquid | 127 + snippets/bento-grid.liquid | 226 + snippets/blog-comment-form.liquid | 206 + snippets/border-override.liquid | 7 + snippets/button.liquid | 45 + snippets/card-gallery.liquid | 368 ++ snippets/cart-bubble.liquid | 41 + snippets/cart-products.liquid | 816 +++ snippets/cart-summary.liquid | 604 ++ snippets/checkbox.liquid | 58 + snippets/collection-card.liquid | 162 + snippets/color-schemes.liquid | 99 + snippets/divider.liquid | 54 + snippets/editorial-blog-grid.liquid | 115 + snippets/editorial-collection-grid.liquid | 117 + snippets/editorial-product-grid.liquid | 125 + snippets/filter-remove-buttons.liquid | 153 + snippets/fonts.liquid | 42 + snippets/format-price.liquid | 31 + snippets/gap-style.liquid | 25 + snippets/gift-card-recipient-form.liquid | 414 ++ snippets/grid-density-controls.liquid | 203 + snippets/group.liquid | 90 + snippets/header-actions.liquid | 684 +++ snippets/header-drawer.liquid | 1391 +++++ snippets/header-row.liquid | 94 + snippets/icon-or-image.liquid | 40 + snippets/icon.liquid | 399 ++ snippets/image.liquid | 80 +- snippets/jumbo-text.liquid | 176 + snippets/layout-panel-style.liquid | 33 + snippets/link-featured-image.liquid | 47 + snippets/list-filter.liquid | 891 +++ snippets/localization-form.liquid | 571 ++ snippets/media.liquid | 117 + snippets/mega-menu-list.liquid | 345 ++ snippets/menu-font-styles.liquid | 23 + snippets/meta-tags.liquid | 28 +- snippets/overflow-list.liquid | 67 + snippets/overlay.liquid | 38 + snippets/pagination-controls.liquid | 365 ++ snippets/predictive-search-empty-state.liquid | 44 + .../predictive-search-products-list.liquid | 185 + ...predictive-search-resource-carousel.liquid | 130 + snippets/price-filter.liquid | 238 + snippets/price.liquid | 126 + snippets/product-card.liquid | 136 + snippets/product-grid.liquid | 221 + snippets/product-information-content.liquid | 310 ++ snippets/product-media-gallery-content.liquid | 858 +++ snippets/product-media.liquid | 208 + snippets/quantity-selector.liquid | 182 + snippets/quick-add-modal.liquid | 557 ++ snippets/quick-add.liquid | 309 + snippets/resource-card.liquid | 308 + snippets/resource-image.liquid | 214 + snippets/resource-list-carousel.liquid | 58 + snippets/resource-list.liquid | 176 + snippets/scripts.liquid | 306 + snippets/search-modal.liquid | 511 ++ snippets/search.liquid | 63 + snippets/section.liquid | 97 + snippets/size-style.liquid | 33 + snippets/skip-to-content-link.liquid | 36 + snippets/sku.liquid | 20 + snippets/slideshow-arrow.liquid | 49 + snippets/slideshow-arrows.liquid | 29 + snippets/slideshow-controls.liquid | 539 ++ snippets/slideshow-slide.liquid | 62 + snippets/slideshow.liquid | 131 + snippets/sorting.liquid | 351 ++ snippets/spacing-padding.liquid | 11 + snippets/spacing-style.liquid | 36 + snippets/strikethrough-variant.liquid | 12 + snippets/stylesheets.liquid | 2 + snippets/submenu-font-styles.liquid | 48 + snippets/swatch.liquid | 48 + snippets/tax-info.liquid | 84 + snippets/text.liquid | 220 + snippets/theme-editor.liquid | 5 + snippets/theme-styles-variables.liquid | 687 +++ snippets/typography-style.liquid | 80 + snippets/unit-price.liquid | 16 + snippets/util-autofill-img-size-attr.liquid | 73 + snippets/util-mega-menu-img-sizes-attr.liquid | 87 + snippets/util-product-grid-card-size.liquid | 45 + snippets/util-product-media-sizes-attr.liquid | 142 + snippets/variant-main-picker.liquid | 297 + snippets/variant-swatches.liquid | 181 + snippets/video.liquid | 216 + snippets/volume-pricing-info.liquid | 302 + templates/404.json | 269 +- templates/article.json | 88 +- templates/blog.json | 102 +- templates/cart.json | 256 +- templates/collection.json | 232 +- templates/gift_card.liquid | 243 +- templates/index.json | 278 +- templates/list-collections.json | 166 +- templates/page.contact.json | 135 + templates/page.json | 57 +- templates/password.json | 135 +- templates/product.json | 405 +- templates/search.json | 178 +- 415 files changed, 128124 insertions(+), 566 deletions(-) create mode 100644 .planning/ARCHITECTURE.md create mode 100644 .planning/EXECUTION-PLAN.md create mode 100644 HORIZON_VERSION.md create mode 100644 LICENSE-HORIZON.md rename LICENSE.md => LICENSE-SKELETON.md (100%) create mode 100644 assets/accordion-custom.js create mode 100644 assets/account-login-actions.js create mode 100644 assets/anchored-popover.js create mode 100644 assets/announcement-bar.js create mode 100644 assets/auto-close-details.js create mode 100644 assets/base.css create mode 100644 assets/blog-posts-list.js create mode 100644 assets/cart-discount.js create mode 100644 assets/cart-drawer.js create mode 100644 assets/cart-icon.js create mode 100644 assets/cart-note.js create mode 100644 assets/collection-links.js create mode 100644 assets/comparison-slider.js create mode 100644 assets/component-cart-items.js create mode 100644 assets/component-cart-quantity-selector.js create mode 100644 assets/component-quantity-selector.js create mode 100644 assets/component.js create mode 100644 assets/copy-to-clipboard.js create mode 100644 assets/dialog.js create mode 100644 assets/drag-zoom-wrapper.js create mode 100644 assets/events.js create mode 100644 assets/facets.js create mode 100644 assets/floating-panel.js create mode 100644 assets/fly-to-cart.js create mode 100644 assets/focus.js create mode 100644 assets/gift-card-recipient-form.js create mode 100644 assets/global.d.ts create mode 100644 assets/header-drawer.js create mode 100644 assets/header-menu.js create mode 100644 assets/header.js create mode 100644 assets/icon-add-to-cart.svg create mode 100644 assets/icon-arrow.svg create mode 100644 assets/icon-available.svg create mode 100644 assets/icon-caret.svg create mode 100644 assets/icon-checkmark-burst.svg create mode 100644 assets/icon-checkmark.svg create mode 100644 assets/icon-chevron-left.svg create mode 100644 assets/icon-chevron-right.svg create mode 100644 assets/icon-close.svg create mode 100644 assets/icon-delete.svg create mode 100644 assets/icon-discount.svg create mode 100644 assets/icon-double-chevron.svg create mode 100644 assets/icon-error.svg create mode 100644 assets/icon-external.svg create mode 100644 assets/icon-filter.svg create mode 100644 assets/icon-filters-close.svg create mode 100644 assets/icon-grid-default.svg create mode 100644 assets/icon-grid-dense.svg create mode 100644 assets/icon-info.svg create mode 100644 assets/icon-inventory.svg create mode 100644 assets/icon-menu.svg create mode 100644 assets/icon-minus.svg create mode 100644 assets/icon-one-col-mobile.svg create mode 100644 assets/icon-orders.svg create mode 100644 assets/icon-pause.svg create mode 100644 assets/icon-play.svg create mode 100644 assets/icon-plus.svg create mode 100644 assets/icon-reset.svg create mode 100644 assets/icon-search.svg create mode 100644 assets/icon-shopify.svg create mode 100644 assets/icon-unavailable.svg create mode 100644 assets/jsconfig.json create mode 100644 assets/jumbo-text.js create mode 100644 assets/layered-slideshow.js create mode 100644 assets/local-pickup.js create mode 100644 assets/localization.js create mode 100644 assets/marquee.js create mode 100644 assets/media-gallery.js create mode 100644 assets/media.js create mode 100644 assets/money-formatting.js create mode 100644 assets/morph.js create mode 100644 assets/overflow-list.css create mode 100644 assets/overflow-list.js create mode 100644 assets/paginated-list-aspect-ratio.js create mode 100644 assets/paginated-list.js create mode 100644 assets/performance.js create mode 100644 assets/popover-polyfill.js create mode 100644 assets/predictive-search.js create mode 100644 assets/price-per-item.js create mode 100644 assets/product-card-link.js create mode 100644 assets/product-card.js create mode 100644 assets/product-custom-property.js create mode 100644 assets/product-form.js create mode 100644 assets/product-hotspot.js create mode 100644 assets/product-inventory.js create mode 100644 assets/product-price.js create mode 100644 assets/product-recommendations.js create mode 100644 assets/product-sku.js create mode 100644 assets/product-title-truncation.js create mode 100644 assets/qr-code-generator.js create mode 100644 assets/qr-code-image.js create mode 100644 assets/quick-add.js create mode 100644 assets/quick-order-list.js create mode 100644 assets/recently-viewed-products.js create mode 100644 assets/results-list.js create mode 100644 assets/rte-formatter.js create mode 100644 assets/scrolling.js create mode 100644 assets/search-page-input.js create mode 100644 assets/section-hydration.js create mode 100644 assets/section-renderer.js create mode 100644 assets/show-more.js create mode 100644 assets/slideshow.js create mode 100644 assets/sticky-add-to-cart.js create mode 100644 assets/template-giftcard.css create mode 100644 assets/theme-editor.js create mode 100644 assets/utilities.js create mode 100644 assets/variant-picker.js create mode 100644 assets/video-background.js create mode 100644 assets/view-transitions.js create mode 100644 assets/volume-pricing-info.js create mode 100644 assets/volume-pricing.js create mode 100644 assets/zoom-dialog.js create mode 100644 blocks/_accordion-row.liquid create mode 100644 blocks/_announcement.liquid create mode 100644 blocks/_blog-post-card.liquid create mode 100644 blocks/_blog-post-content.liquid create mode 100644 blocks/_blog-post-description.liquid create mode 100644 blocks/_blog-post-featured-image.liquid create mode 100644 blocks/_blog-post-image.liquid create mode 100644 blocks/_blog-post-info-text.liquid create mode 100644 blocks/_card.liquid create mode 100644 blocks/_carousel-content.liquid create mode 100644 blocks/_cart-products.liquid create mode 100644 blocks/_cart-summary.liquid create mode 100644 blocks/_cart-title.liquid create mode 100644 blocks/_collection-card-image.liquid create mode 100644 blocks/_collection-card.liquid create mode 100644 blocks/_collection-image.liquid create mode 100644 blocks/_collection-info.liquid create mode 100644 blocks/_collection-link.liquid create mode 100644 blocks/_content-without-appearance.liquid create mode 100644 blocks/_content.liquid create mode 100644 blocks/_divider.liquid create mode 100644 blocks/_featured-blog-posts-card.liquid create mode 100644 blocks/_featured-blog-posts-image.liquid create mode 100644 blocks/_featured-blog-posts-title.liquid create mode 100644 blocks/_featured-product-gallery.liquid create mode 100644 blocks/_featured-product-information-carousel.liquid create mode 100644 blocks/_featured-product-price.liquid create mode 100644 blocks/_featured-product.liquid create mode 100644 blocks/_footer-social-icons.liquid create mode 100644 blocks/_header-logo.liquid create mode 100644 blocks/_header-menu.liquid create mode 100644 blocks/_heading.liquid create mode 100644 blocks/_hotspot-product.liquid create mode 100644 blocks/_image.liquid create mode 100644 blocks/_inline-collection-title.liquid create mode 100644 blocks/_inline-text.liquid create mode 100644 blocks/_layered-slide.liquid create mode 100644 blocks/_marquee.liquid create mode 100644 blocks/_media-without-appearance.liquid create mode 100644 blocks/_media.liquid create mode 100644 blocks/_product-card-gallery.liquid create mode 100644 blocks/_product-card-group.liquid create mode 100644 blocks/_product-card.liquid create mode 100644 blocks/_product-details.liquid create mode 100644 blocks/_product-list-button.liquid create mode 100644 blocks/_product-list-content.liquid create mode 100644 blocks/_product-list-text.liquid create mode 100644 blocks/_product-media-gallery.liquid create mode 100644 blocks/_search-input.liquid create mode 100644 blocks/_slide.liquid create mode 100644 blocks/_social-link.liquid create mode 100644 blocks/accelerated-checkout.liquid create mode 100644 blocks/accordion.liquid create mode 100644 blocks/add-to-cart.liquid create mode 100644 blocks/button.liquid create mode 100644 blocks/buy-buttons.liquid create mode 100644 blocks/collection-card.liquid create mode 100644 blocks/collection-title.liquid create mode 100644 blocks/comparison-slider.liquid create mode 100644 blocks/contact-form-submit-button.liquid create mode 100644 blocks/contact-form.liquid create mode 100644 blocks/custom-liquid.liquid create mode 100644 blocks/email-signup.liquid create mode 100644 blocks/featured-collection.liquid create mode 100644 blocks/filters.liquid create mode 100644 blocks/follow-on-shop.liquid create mode 100644 blocks/footer-copyright.liquid create mode 100644 blocks/footer-policy-list.liquid create mode 100644 blocks/icon.liquid create mode 100644 blocks/image.liquid create mode 100644 blocks/jumbo-text.liquid create mode 100644 blocks/logo.liquid create mode 100644 blocks/menu.liquid create mode 100644 blocks/page-content.liquid create mode 100644 blocks/page.liquid create mode 100644 blocks/payment-icons.liquid create mode 100644 blocks/popup-link.liquid create mode 100644 blocks/price.liquid create mode 100644 blocks/product-card.liquid create mode 100644 blocks/product-custom-property.liquid create mode 100644 blocks/product-description.liquid create mode 100644 blocks/product-inventory.liquid create mode 100644 blocks/product-recommendations.liquid create mode 100644 blocks/product-title.liquid create mode 100644 blocks/quantity.liquid create mode 100644 blocks/review.liquid create mode 100644 blocks/sku.liquid create mode 100644 blocks/social-links.liquid create mode 100644 blocks/spacer.liquid create mode 100644 blocks/swatches.liquid create mode 100644 blocks/variant-picker.liquid create mode 100644 blocks/video.liquid create mode 100644 locales/bg.json create mode 100644 locales/cs.json create mode 100644 locales/cs.schema.json create mode 100644 locales/da.json create mode 100644 locales/da.schema.json create mode 100644 locales/de.json create mode 100644 locales/de.schema.json create mode 100644 locales/el.json create mode 100644 locales/es.json create mode 100644 locales/es.schema.json create mode 100644 locales/fi.json create mode 100644 locales/fi.schema.json create mode 100644 locales/fr.json create mode 100644 locales/fr.schema.json create mode 100644 locales/hr.json create mode 100644 locales/hu.json create mode 100644 locales/id.json create mode 100644 locales/it.json create mode 100644 locales/it.schema.json create mode 100644 locales/ja.json create mode 100644 locales/ja.schema.json create mode 100644 locales/ko.json create mode 100644 locales/ko.schema.json create mode 100644 locales/lt.json create mode 100644 locales/nb.json create mode 100644 locales/nb.schema.json create mode 100644 locales/nl.json create mode 100644 locales/nl.schema.json create mode 100644 locales/pl.json create mode 100644 locales/pl.schema.json create mode 100644 locales/pt-BR.json create mode 100644 locales/pt-BR.schema.json create mode 100644 locales/pt-PT.json create mode 100644 locales/pt-PT.schema.json create mode 100644 locales/ro.json create mode 100644 locales/ru.json create mode 100644 locales/sk.json create mode 100644 locales/sl.json create mode 100644 locales/sv.json create mode 100644 locales/sv.schema.json create mode 100644 locales/th.json create mode 100644 locales/th.schema.json create mode 100644 locales/tr.json create mode 100644 locales/tr.schema.json create mode 100644 locales/vi.json create mode 100644 locales/zh-CN.json create mode 100644 locales/zh-CN.schema.json create mode 100644 locales/zh-TW.json create mode 100644 locales/zh-TW.schema.json create mode 100644 sections/_blocks.liquid create mode 100644 sections/carousel.liquid create mode 100644 sections/collection-links.liquid create mode 100644 sections/collection-list.liquid create mode 100644 sections/custom-liquid.liquid create mode 100644 sections/divider.liquid create mode 100644 sections/featured-blog-posts.liquid create mode 100644 sections/featured-product-information.liquid create mode 100644 sections/featured-product.liquid create mode 100644 sections/footer-utilities.liquid create mode 100644 sections/header-announcements.liquid create mode 100644 sections/hero.liquid create mode 100644 sections/layered-slideshow.liquid create mode 100644 sections/logo.liquid create mode 100644 sections/main-404.liquid create mode 100644 sections/main-blog-post.liquid create mode 100644 sections/main-blog.liquid create mode 100644 sections/main-cart.liquid create mode 100644 sections/main-collection-list.liquid create mode 100644 sections/main-collection.liquid create mode 100644 sections/main-page.liquid create mode 100644 sections/marquee.liquid create mode 100644 sections/media-with-content.liquid create mode 100644 sections/password-footer.liquid create mode 100644 sections/predictive-search-empty.liquid create mode 100644 sections/predictive-search.liquid create mode 100644 sections/product-hotspots.liquid create mode 100644 sections/product-information.liquid create mode 100644 sections/product-list.liquid create mode 100644 sections/product-recommendations.liquid create mode 100644 sections/quick-order-list.liquid create mode 100644 sections/search-header.liquid create mode 100644 sections/search-results.liquid create mode 100644 sections/section-rendering-product-card.liquid create mode 100644 sections/section.liquid create mode 100644 sections/slideshow.liquid create mode 100644 snippets/account-actions.liquid create mode 100644 snippets/account-button.liquid create mode 100644 snippets/add-to-cart-button.liquid create mode 100644 snippets/background-media.liquid create mode 100644 snippets/bento-grid.liquid create mode 100644 snippets/blog-comment-form.liquid create mode 100644 snippets/border-override.liquid create mode 100644 snippets/button.liquid create mode 100644 snippets/card-gallery.liquid create mode 100644 snippets/cart-bubble.liquid create mode 100644 snippets/cart-products.liquid create mode 100644 snippets/cart-summary.liquid create mode 100644 snippets/checkbox.liquid create mode 100644 snippets/collection-card.liquid create mode 100644 snippets/color-schemes.liquid create mode 100644 snippets/divider.liquid create mode 100644 snippets/editorial-blog-grid.liquid create mode 100644 snippets/editorial-collection-grid.liquid create mode 100644 snippets/editorial-product-grid.liquid create mode 100644 snippets/filter-remove-buttons.liquid create mode 100644 snippets/fonts.liquid create mode 100644 snippets/format-price.liquid create mode 100644 snippets/gap-style.liquid create mode 100644 snippets/gift-card-recipient-form.liquid create mode 100644 snippets/grid-density-controls.liquid create mode 100644 snippets/group.liquid create mode 100644 snippets/header-actions.liquid create mode 100644 snippets/header-drawer.liquid create mode 100644 snippets/header-row.liquid create mode 100644 snippets/icon-or-image.liquid create mode 100644 snippets/icon.liquid create mode 100644 snippets/jumbo-text.liquid create mode 100644 snippets/layout-panel-style.liquid create mode 100644 snippets/link-featured-image.liquid create mode 100644 snippets/list-filter.liquid create mode 100644 snippets/localization-form.liquid create mode 100644 snippets/media.liquid create mode 100644 snippets/mega-menu-list.liquid create mode 100644 snippets/menu-font-styles.liquid create mode 100644 snippets/overflow-list.liquid create mode 100644 snippets/overlay.liquid create mode 100644 snippets/pagination-controls.liquid create mode 100644 snippets/predictive-search-empty-state.liquid create mode 100644 snippets/predictive-search-products-list.liquid create mode 100644 snippets/predictive-search-resource-carousel.liquid create mode 100644 snippets/price-filter.liquid create mode 100644 snippets/price.liquid create mode 100644 snippets/product-card.liquid create mode 100644 snippets/product-grid.liquid create mode 100644 snippets/product-information-content.liquid create mode 100644 snippets/product-media-gallery-content.liquid create mode 100644 snippets/product-media.liquid create mode 100644 snippets/quantity-selector.liquid create mode 100644 snippets/quick-add-modal.liquid create mode 100644 snippets/quick-add.liquid create mode 100644 snippets/resource-card.liquid create mode 100644 snippets/resource-image.liquid create mode 100644 snippets/resource-list-carousel.liquid create mode 100644 snippets/resource-list.liquid create mode 100644 snippets/scripts.liquid create mode 100644 snippets/search-modal.liquid create mode 100644 snippets/search.liquid create mode 100644 snippets/section.liquid create mode 100644 snippets/size-style.liquid create mode 100644 snippets/skip-to-content-link.liquid create mode 100644 snippets/sku.liquid create mode 100644 snippets/slideshow-arrow.liquid create mode 100644 snippets/slideshow-arrows.liquid create mode 100644 snippets/slideshow-controls.liquid create mode 100644 snippets/slideshow-slide.liquid create mode 100644 snippets/slideshow.liquid create mode 100644 snippets/sorting.liquid create mode 100644 snippets/spacing-padding.liquid create mode 100644 snippets/spacing-style.liquid create mode 100644 snippets/strikethrough-variant.liquid create mode 100644 snippets/stylesheets.liquid create mode 100644 snippets/submenu-font-styles.liquid create mode 100644 snippets/swatch.liquid create mode 100644 snippets/tax-info.liquid create mode 100644 snippets/text.liquid create mode 100644 snippets/theme-editor.liquid create mode 100644 snippets/theme-styles-variables.liquid create mode 100644 snippets/typography-style.liquid create mode 100644 snippets/unit-price.liquid create mode 100644 snippets/util-autofill-img-size-attr.liquid create mode 100644 snippets/util-mega-menu-img-sizes-attr.liquid create mode 100644 snippets/util-product-grid-card-size.liquid create mode 100644 snippets/util-product-media-sizes-attr.liquid create mode 100644 snippets/variant-main-picker.liquid create mode 100644 snippets/variant-swatches.liquid create mode 100644 snippets/video.liquid create mode 100644 snippets/volume-pricing-info.liquid create mode 100644 templates/page.contact.json diff --git a/.planning/ARCHITECTURE.md b/.planning/ARCHITECTURE.md new file mode 100644 index 000000000..018a87b28 --- /dev/null +++ b/.planning/ARCHITECTURE.md @@ -0,0 +1,2272 @@ +# Shopify Theme Agency Core System - Implementation Plan + +**Project**: Build core theme as NPM package for scalable multi-client deployment +**Architecture**: NPM Package (separate repos per theme) +**Base**: Skeleton (Shopify's official baseline) + Horizon features (one-time copy) +**Strategy**: Core as npm package + client overlays with semantic versioning + +**Related Plans**: +- **Hanro Migration**: See `hanro-migration.md` for pilot client migration details + +--- + +## Plan Evolution + +**Updated**: January 29, 2026 - Revised base theme strategy based on CTO feedback + +**Change**: Base theme approach +- **Previous**: Fork from Horizon, track Horizon as upstream +- **Current**: Fork from Skeleton (stable baseline), copy ALL Horizon code as starting point +- **Rationale**: Full control, avoid merge conflicts, empower merchants with full section library, organizational behavior alignment + +**Impact**: Architectural concepts remain the same (NPM package, build system, override management, etc.). Only base theme source and update strategy changed. + +--- + +## Executive Summary + +Publish a core theme as an NPM package (`@agency/shopify-core`) that client themes install as a dependency. This enables continuous core updates via standard npm workflows AND client-specific customizations through overlay files. + +**Core Philosophy**: Core theme is an npm dependency, not a Git submodule or monorepo directory. Clients control update timing via semantic versioning. + +**Base Theme Strategy**: Fork Shopify's Skeleton theme (minimal baseline structure) and copy ALL Horizon features as starting point. This provides: +- ✅ Stable baseline (Skeleton changes rarely, just structure) +- ✅ Full feature set (all 94 Horizon blocks available to merchants) +- ✅ Complete control (no merge conflicts with Horizon updates) +- ✅ Merchant empowerment (full section library in theme editor) +- ✅ Future flexibility (can prune unused blocks after real-world usage data) + +--- + +## Base Theme Selection: Skeleton + Horizon Copy Strategy + +After discussion with CTO, the strategy is to **fork Skeleton and copy Horizon features** rather than forking Horizon directly. + +### The Strategy: Skeleton (Structure) + Horizon (Features) + +**Fork from Skeleton**: +- ✅ Official Shopify baseline for building custom themes +- ✅ Minimal structure (layout, templates, basic sections) +- ✅ Stable (changes rarely, only structural updates) +- ✅ Clear upstream: Skeleton provides baseline, we own features + +**Copy Horizon as Starting Point** (one-time): +- ✅ All 94 blocks (testimonials, galleries, product displays, etc.) +- ✅ All 41 sections (product, cart, collection, etc.) +- ✅ Modern cart drawer, mega menu, filtering +- ✅ Proven features (Shopify's official flagship theme) + +**Result**: `@agency/shopify-core` = Skeleton structure + Horizon features + +--- + +### Why This Approach (CTO's Reasoning) + +**"We're going to diverge from Horizon anyway"**: +- Hanro needs: custom swatches, gender navigation, gift wrap, integrations +- Other clients will need their own customizations +- Trying to stay 1:1 with Horizon creates merge conflicts +- Better to own the code completely + +**"Why deal with the hassle of tracking Horizon updates?"**: +- Quarterly Horizon merges would conflict with customizations (product card, cart, header) +- Manual conflict resolution for 25-50 clients is unsustainable +- Missing automatic Horizon updates is acceptable trade-off + +**"Stay inline with Skeleton since that's Shopify's baseline"**: +- Skeleton is the official foundation for custom themes +- Skeleton changes are structural (rarely) vs. features (never) +- Low maintenance burden for upstream tracking + +**"Merchants should have full section library available"**: +- Horizon's 94 blocks = proven features from millions of stores +- Merchants want drag/drop section library in theme editor +- Selective copy creates "wait for dev ticket" friction +- Full copy empowers merchants to self-serve + +--- + +### Full Copy vs. Selective Copy Decision + +**Why Full Copy (All 94 Horizon Blocks)**: + +1. **Organizational Behavior**: Agency tends to build custom solutions first without checking if Horizon already has it. Result: duplicate implementations (custom hero + Horizon hero = more maintenance). + +2. **Merchant Empowerment**: Blocks architecture designed for merchants to add/remove sections. Full library = self-service. Selective library = dev tickets for each new section. + +3. **Claude Code Maintenance**: AI assistance makes large codebases manageable. 10,000 lines vs. 3,000 lines is not a significant burden with AI tools. + +4. **Shopify's Curation**: Horizon's 94 blocks based on data from millions of stores. Their curation is likely better than our selective guesses. + +5. **Pruning Later**: Can remove unused blocks after 6-12 months of real usage data. Easier to prune than to hunt for what to add. + +**Trade-offs Accepted**: +- ⚠️ Larger NPM package (~2-3MB vs ~1MB) +- ⚠️ Miss automatic Horizon security updates (manual porting required) +- ⚠️ More code to understand initially (10,000+ lines) + +**Trade-offs Gained**: +- 🎯 Full merchant empowerment (all sections available) +- 🎯 Prevents duplicate work (no custom builds when Horizon has it) +- 🎯 Matches organizational reality (no "check Horizon first" discipline required) +- 🎯 Can prune unused blocks after real-world data + +--- + +### Comparison: Three Approaches + +| Approach | Upstream | Feature Updates | Merge Conflicts | Control | Maintenance | +|----------|----------|-----------------|-----------------|---------|-------------| +| **Fork Horizon** (original plan) | Horizon | Automatic (quarterly) | High (conflicts with customizations) | Medium | Shopify shares burden | +| **Fork Skeleton + Copy Horizon** (chosen) | Skeleton | Manual (security only) | None (we own features) | Full | We own all code | +| **Fork Skeleton + Selective Copy** (considered) | Skeleton | Manual (security only) | None | Full | Less code to maintain | + +**Chosen**: Fork Skeleton + Full Horizon Copy +- Best balance of merchant empowerment, organizational behavior, and AI-assisted maintenance + +--- + +### Sources +- [Shopify Skeleton Theme](https://github.com/Shopify/skeleton-theme) +- [Skeleton Announcement](https://shopify.dev/changelog/skeleton-theme-is-now-available) +- [Shopify Horizon Theme](https://github.com/Shopify/horizon) +- [Building Custom Themes with Skeleton](https://shopify.dev/docs/storefronts/themes/architecture) + +--- + +## Repository Structure (Separate Git Repos) + +### Core Theme Repository +``` +shopify-agency-core/ # Forked from Shopify/skeleton +├── .git/ +│ └── remotes/ +│ ├── origin/ # Your fork +│ └── upstream/ # Shopify/skeleton (structure only) +├── assets/ # Horizon features (copied) +├── blocks/ # 94 reusable blocks (from Horizon) +├── sections/ # 41 sections (from Horizon) +├── snippets/ # Universal snippets (from Horizon) +├── templates/ # Base templates (Skeleton + Horizon) +├── config/ # Settings schema (Skeleton + Horizon) +├── layout/ # theme.liquid (Skeleton base) +├── locales/ # i18n files (Skeleton + Horizon) +├── package.json # NPM package manifest +│ { +│ "name": "@agency/shopify-core", +│ "version": "1.2.3", +│ "files": ["assets/**", "blocks/**", "sections/**", ...] +│ } +├── .github/workflows/ +│ └── publish-to-npm.yml # Auto-publish on tag +├── CHANGELOG.md # Version history +├── CORE.md # Core development guide +├── HORIZON_VERSION.md # Documents Horizon source version +├── PRUNING_STRATEGY.md # Usage tracking, removal process +└── README.md # NPM package usage docs +``` + +### Client Theme Repository (Hanro Example) +``` +hanro-theme/ # github.com/agency/hanro-theme +├── assets/ # Core + client files (at root for Shopify) +│ ├── product-card.css # From core (may be customized) +│ ├── hanro-custom.css # Client-specific +│ └── hanro-swatch.js # Client-specific +│ +├── blocks/ # Core + client files +│ ├── _testimonials.liquid # From core +│ └── _hanro-hero.liquid # Client-specific +│ +├── sections/ # Core + client files +│ ├── header.liquid # From core (may be customized) +│ └── hanro-custom-hero.liquid # Client-specific +│ +├── snippets/ # Core + client files +│ └── product-card.liquid # From core (may be customized) +│ +├── templates/ # Core + client files +│ └── product.json # Client-customized +│ +├── config/ +│ ├── settings_data.json # Shopify-managed (merchant edits) +│ └── settings_schema.json # From core + client extensions +│ +├── layout/ +│ └── theme.liquid # From core (may be customized) +│ +├── locales/ # From core + client translations +│ +├── node_modules/ +│ └── @agency/ +│ └── shopify-core/ # Core theme (npm installed, source for install script) +│ +├── .core-version # Tracks installed core version (e.g., "1.2.0") +│ +├── package.json +│ { +│ "name": "hanro-theme", +│ "version": "1.0.0", +│ "dependencies": { +│ "@agency/shopify-core": "^1.2.0" # Core theme dependency +│ }, +│ "scripts": { +│ "install-core": "node scripts/install-core.js", +│ "update-core": "node scripts/update-core.js" +│ } +│ } +│ +├── scripts/ +│ ├── install-core.js # Initial install (copies core → root) +│ └── update-core.js # Update core (copies new version → root) +│ +├── CUSTOMIZATIONS.md # Documents client changes +└── README.md +``` + +**Key Changes from Previous Architecture:** +- ✅ Theme files at repo root (required for Shopify GitHub integration) +- ✅ Core + client files mixed in same directories +- ✅ Install/update scripts replace build script +- ✅ `.core-version` tracks installed core version +- ❌ No `src/` directory (work directly at root) +- ❌ No `build/` directory (root IS the deployable theme) +- ✅ Master branch → Shopify auto-sync (GitHub integration works) +- ✅ Theme editor changes → bot commits to Git (sync works both ways) + +--- + +## NPM Registry: GitHub Packages + +**Decision**: ✅ **GitHub Packages** (finalized) + +Using GitHub Packages as the private npm registry for `@agency/shopify-core`. + +### Why GitHub Packages +- ✅ **Free** for private repos in GitHub org (saves $420/year vs npm Private Packages) +- ✅ **Integrated** with existing GitHub workflow and Actions +- ✅ **Automatic** org member access (no separate account management) +- ✅ **Simple** authentication (PAT tokens, one-time setup per developer) + +### Setup + +**Core Theme Repository** (`shopify-agency-core/package.json`): +```json +{ + "name": "@agency/shopify-core", + "version": "1.0.0", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } +} +``` + +**Developer Setup** (one-time per developer): +```bash +# Generate GitHub PAT with read:packages, write:packages, repo scopes +# GitHub Settings → Developer Settings → Personal Access Tokens + +# Configure npm to use GitHub Packages for @agency scope +echo "@agency:registry=https://npm.pkg.github.com" >> ~/.npmrc +echo "//npm.pkg.github.com/:_authToken=YOUR_PAT_HERE" >> ~/.npmrc + +# Test authentication +npm login --registry=https://npm.pkg.github.com --scope=@agency +``` + +**Client Repository Setup** (automatic): +```bash +# Client repos inherit @agency scope registry from developer's ~/.npmrc +npm install @agency/shopify-core@^1.0.0 +# Works automatically once developer configured ~/.npmrc +``` + +### Fallback Plan +If GitHub Packages proves unreliable, migrate to **npm Private Packages** ($7/user/month): +- Change `publishConfig.registry` in package.json +- Update developer `.npmrc` files +- Re-publish package to npm registry +- No code changes needed + +--- + +## How NPM Package + Install Script Architecture Works + +### Core vs. Client Code (Mixed at Root) + +**Core Theme** (installed from `@agency/shopify-core`): +- ✅ All Horizon blocks library (94 blocks) - copied to root +- ✅ Base sections (hero, product-information, collection-list, etc.) - copied to root +- ✅ Universal snippets (price, card, button) - copied to root +- ✅ Foundation CSS/JS - copied to root +- ✅ Schema settings - copied to root +- 📦 Installed via npm to `node_modules/`, then **copied to root via install script** + +**Client Customizations** (at root, mixed with core): +- ✅ New client-specific files (e.g., `blocks/_hanro-hero.liquid`) +- ✅ CSS overrides (e.g., `assets/hanro-custom.css`) +- ✅ Modified core files (e.g., customized `assets/product-card.css`) +- ✅ All files at root (required for Shopify GitHub integration) + +**Tracking:** +- `.core-version` file contains current core version (e.g., "1.2.0") +- Client files vs core files identified via Git history +- Modified core files tracked via Git diff + +### Install Script (Initial Setup) + +```javascript +// scripts/install-core.js (in each client repo) +const fs = require('fs-extra'); +const path = require('path'); + +async function installCore() { + const coreDir = 'node_modules/@agency/shopify-core'; + const rootDir = '.'; + + // Get core version + const corePkg = await fs.readJson(path.join(coreDir, 'package.json')); + const coreVersion = corePkg.version; + + console.log(`Installing core theme v${coreVersion}...`); + + // Copy all core files to root + const filesToCopy = [ + 'assets', + 'blocks', + 'sections', + 'snippets', + 'templates', + 'config', + 'layout', + 'locales' + ]; + + for (const dir of filesToCopy) { + const srcPath = path.join(coreDir, dir); + const destPath = path.join(rootDir, dir); + + if (await fs.pathExists(srcPath)) { + await fs.copy(srcPath, destPath, { overwrite: false }); + // overwrite: false means don't overwrite existing client files + // Only copies files that don't exist yet + } + } + + // Save core version + await fs.writeFile('.core-version', coreVersion); + + console.log('✓ Core theme installed successfully'); + console.log(`✓ Version: ${coreVersion}`); + console.log('→ Files copied to root (ready for Shopify GitHub sync)'); +} + +installCore().catch(console.error); +``` + +```javascript +// scripts/update-core.js (in each client repo) +const fs = require('fs-extra'); +const path = require('path'); + +async function updateCore() { + const coreDir = 'node_modules/@agency/shopify-core'; + + // Get versions + const currentVersion = (await fs.readFile('.core-version', 'utf8')).trim(); + const corePkg = await fs.readJson(path.join(coreDir, 'package.json')); + const newVersion = corePkg.version; + + console.log(`Updating core: ${currentVersion} → ${newVersion}`); + + // Copy ALL core files to root (overwrite everything) + const filesToCopy = ['assets', 'blocks', 'sections', 'snippets', 'templates', 'config', 'layout', 'locales']; + + for (const dir of filesToCopy) { + const srcPath = path.join(coreDir, dir); + const destPath = path.join('.', dir); + + if (await fs.pathExists(srcPath)) { + await fs.copy(srcPath, destPath, { overwrite: true }); + // overwrite: true - copy ALL files, even if client modified + // Git will show conflicts on merge + } + } + + // Update version file + await fs.writeFile('.core-version', newVersion); + + console.log('✓ Core files updated'); + console.log('⚠️ Review changes with: git status'); + console.log('⚠️ Client-modified files will show as changed'); + console.log('→ Commit and merge to preserve both core updates + client customizations'); +} + +updateCore().catch(console.error); +``` + +**How Updates Work:** +1. **Update script copies ALL files** from core (overwrites everything at root) +2. **Git detects changes** (shows which core files were updated) +3. **Merge conflict** if client modified a file that core also updated +4. **Developer resolves** (keeps both client customizations + core updates) +5. **Commit and push** → Shopify GitHub integration syncs automatically + +### Customization Patterns + +#### Pattern 1: CSS Overrides (Most Common) +```css +/* Core theme: assets/app.css (from @agency/shopify-core) */ +:root { + --color-primary: #000000; + --font-heading: 'Inter', sans-serif; +} +``` + +```css +/* Client addition: assets/hanro-custom.css (client-specific) */ +:root { + --color-primary: #232323; /* Hanro brand color */ + --font-heading: 'Futura', sans-serif; +} +``` + +**Result**: Both files exist at root. Client CSS loads after core (via theme.liquid order), overrides win via CSS cascade. + +#### Pattern 2: New Client-Specific Section +Core provides base `hero.liquid`. Client creates `sections/hanro-hero.liquid` at root. Both available in theme editor (side by side). + +#### Pattern 3: Modify Core File +Client needs different product card behavior → modify `assets/product-card.css` at root directly. When core updates, Git merge combines both changes. + +--- + +## Git & Branching Strategy + +### Core Theme Repository (shopify-agency-core) + +**Git Workflow**: +``` +github.com/agency/shopify-agency-core: + main → Production-ready core (auto-publishes to npm) + develop → Integration branch + feature/* → Feature development + release/v1.2.0 → Release branches (optional) +``` + +**NPM Publishing Workflow**: +1. Merge PR to `main` +2. Update `package.json` version: `1.2.3` +3. Git tag: `v1.2.3` +4. GitHub Actions auto-publishes to npm registry +5. Clients can now `npm update @agency/shopify-core` + +**Versioning**: Semantic versioning (package.json) +- **Major (2.0.0)**: Breaking changes (clients must manually upgrade) +- **Minor (1.2.0)**: New features (backward compatible, auto-updateable with ^) +- **Patch (1.2.3)**: Bug fixes (auto-updateable) + +--- + +### Skeleton Upstream Tracking + Horizon Reference Strategy + +**Skeleton Upstream** (Rare structural updates): +```bash +# Core repo is forked from Shopify/skeleton +git remote add upstream https://github.com/Shopify/skeleton.git +git fetch upstream +``` + +**Skeleton Update Process** (Annual or as-needed): +```bash +# Skeleton changes are rare (structural only, not features) +# Example: Shopify changes theme.liquid structure + +# 1. Review Skeleton's changelog +git fetch upstream +git log upstream/main --oneline --since="1 year ago" +# Review: Structural changes only + +# 2. Create review branch +git checkout -b review/skeleton-update + +# 3. Merge structural changes +git merge upstream/main +# Conflicts should be minimal (structure vs. features) + +# 4. Test thoroughly +shopify theme check +# Validate theme structure still valid (files are at root) + +# 5. Publish new core version +git checkout main +git merge review/skeleton-update +git tag v1.5.0 +npm version minor +npm publish +``` + +**Frequency**: +- **Skeleton updates**: Annually or less (very stable) +- **Your core updates**: Weekly (52/year) +- **Impact**: 99% of updates are yours, <1% are Skeleton + +--- + +### Horizon Reference Strategy (No Upstream Tracking) + +**Horizon is Reference, Not Upstream**: +- Horizon code was copied ONE TIME (initial setup) +- No ongoing Horizon merges +- We own and maintain all Horizon features we copied + +**Horizon Version Documentation**: +```markdown +# HORIZON_VERSION.md + +Core theme copied from Shopify Horizon v3.2.1 +Date: January 2026 +Commit: abc123def456 +Repository: https://github.com/Shopify/horizon + +Copied components: +- All 94 blocks (blocks/) +- All 41 sections (sections/) +- All assets (assets/) +- All snippets (snippets/) +- Settings schema (config/settings_schema.json) + +This documentation allows us to: +1. Reference original Horizon code if needed +2. Manually port critical security fixes +3. Track divergence from Horizon +4. Know which Horizon version clients' questions reference +``` + +**Security Update Process** (Manual, as-needed): +```bash +# If Horizon releases critical security fix: + +# 1. Review Horizon changelog +# Visit: https://github.com/Shopify/horizon/releases + +# 2. Assess impact on our copied code +# Does the fix affect code we copied? + +# 3. Manual port (using Claude Code) +# Read Horizon's fix +# Apply equivalent fix to our codebase +# Test thoroughly + +# 4. Publish updated core version +git tag v1.5.1 # Patch version for security +npm publish + +# 5. Notify clients to update +``` + +**Why This Works**: +- ✅ No merge conflicts (we own all code) +- ✅ Full control over features +- ✅ Can diverge without guilt +- ✅ Stable baseline (Skeleton) separate from features (Horizon) +- ✅ Security fixes manual but manageable with AI assistance + +--- + +### Annual Pruning Strategy + +**Goal**: Remove unused Horizon blocks after real-world usage data + +**Process** (Every December): +```bash +# 1. Generate usage report +# Track which blocks/sections are actually used across all clients + +# 2. Candidate list for removal +# Blocks with 0 usage across all clients for 6+ months + +# 3. Team review +# "Do we keep for future clients or archive?" + +# 4. Remove or archive +mkdir -p archived-blocks/ +git mv blocks/_unused-testimonials.liquid archived-blocks/ + +# 5. Update documentation +# PRUNING_LOG.md: "Removed testimonials block (0 usage, archived)" + +# 6. Publish new core version +git tag v2.0.0 # Major version (removed features) +npm publish + +# 7. Notify clients (opt-in update) +# Clients on v1.x.x continue working +# Clients wanting v2.0.0 must verify they don't use removed blocks +``` + +**Benefits**: +- Start generous (all 94 blocks available) +- Prune based on real data (not guesses) +- Lighter codebase over time +- Still have archived code if needed later + +### Client Theme Repository (hanro-theme) + +**Git Workflow**: +``` +github.com/agency/hanro-theme: + master → Live theme (Shopify production) + staging → QA environment + feature/HAN-### → Client-specific features (JIRA pattern) + upgrade/core-v1.3.0 → Core upgrade branches (major versions only) +``` + +### Weekly Core Update Flow + +**Automated with npm + GitHub Actions**: + +**Option A: Automatic Minor/Patch Updates** (Recommended) +```yaml +# .github/workflows/auto-update-core.yml +name: Auto-update Core Theme +on: + schedule: + - cron: '0 10 * * FRI' # Every Friday 10 AM + workflow_dispatch: + +jobs: + update-core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Update core dependency + run: | + npm update @agency/shopify-core + # Updates to latest ^1.x.x (minor/patch only) + + - name: Run update script + run: npm run update-core + # Copies new core files to root + + - name: Validate theme + run: shopify theme check + # Theme files are at root (Shopify GitHub integration) + + - name: Commit core update + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add . + git commit -m "chore: update core to $(cat .core-version)" + git push origin staging + # Push to staging branch → Shopify auto-syncs via GitHub integration + + - name: Create PR if changes + if: success() + run: | + git add package.json package-lock.json + git commit -m "chore: update core to latest version" + gh pr create --title "Update core theme" --body "Automated core update" +``` + +**Option B: Manual Updates** (More control) +```bash +# Developer manually updates when ready +cd hanro-theme/ + +# Create update branch +git checkout -b update/core-v1.3.2 + +# Update and install +npm update @agency/shopify-core # Updates to latest ^1.x.x +npm run update-core # Copies to root + +# Review changes +git diff + +# Commit update +git add . +git commit -m "Update core to v1.3.2" + +# Merge to master +git checkout master +git merge update/core-v1.3.2 +# Resolve any conflicts + +# Push → Shopify auto-syncs +git push origin master +``` + +### Handling Core Updates + +**Scenario 1: Minor/Patch Update (Clean Update, No Client Changes)** +```bash +# Core publishes v1.2.3 (bug fix) +# Client has "@agency/shopify-core": "^1.2.0" +# Client hasn't modified the files that core updated + +git checkout -b update/core-v1.2.3 +npm update @agency/shopify-core # → Installs 1.2.3 +npm run update-core # Copies new core files to root + +git add . +git commit -m "Update core to v1.2.3" + +git checkout master +git merge update/core-v1.2.3 +# No conflicts (client didn't modify affected files) + +git push origin master +# Shopify auto-syncs ✅ +``` + +**Scenario 2: Client Modified Core File** +```bash +# Core updates sections/header.liquid +# Client previously modified sections/header.liquid + +git checkout -b update/core-v1.2.0 +npm update @agency/shopify-core +npm run update-core # Copies new core version → overwrites client version + +git status +# modified: sections/header.liquid + +git add . +git commit -m "Update core to v1.2.0" + +git checkout master +git merge update/core-v1.2.0 +# CONFLICT: sections/header.liquid +# (client's custom menu vs core's update) + +# Resolve merge conflict (keep both changes) +git add sections/header.liquid +git commit -m "Merge core v1.2.0 (resolved conflicts)" +``` + +**Scenario 3: Major Version (Breaking Change)** +```bash +# Core publishes v2.0.0 (breaking changes - removed 20 unused blocks) +# Client has "@agency/shopify-core": "^1.2.0" + +npm update @agency/shopify-core # Stays on 1.x (^ doesn't cross major) + +# Manual upgrade required: +git checkout -b upgrade/core-v2.0.0 +npm install @agency/shopify-core@^2.0.0 +npm run update-core +# Copies v2.0.0 files to root (some blocks removed) + +git add . +git commit -m "Upgrade core to v2.0.0" + +# Review CHANGELOG for breaking changes +# Test thoroughly + +git checkout master +git merge upgrade/core-v2.0.0 +# Resolve any conflicts, test thoroughly before merging +``` + +--- + +## Developer Workflow + +### Working on Core Theme (shopify-agency-core repo) + +```bash +# Clone core repo +git clone github.com/agency/shopify-agency-core +cd shopify-agency-core/ + +# Create feature branch +git checkout -b feature/blocks/testimonials + +# Develop with live preview (test changes directly) +shopify theme dev --store=core-development.myshopify.com + +# Create new block +mkdir -p blocks/ +cat > blocks/_testimonials.liquid << 'EOF' +{% schema %} +{ + "name": "Testimonials", + "settings": [...] +} +{% endschema %} +EOF + +# Validate +shopify theme check + +# Commit & PR +git add blocks/_testimonials.liquid +git commit -m "feat: add testimonials block with rating display" +git push origin feature/blocks/testimonials +# → Create PR to main + +# After PR approval & merge to main: +# 1. Update package.json version: 1.0.0 → 1.1.0 (new feature = minor bump) +# 2. Git tag: v1.1.0 +# 3. GitHub Actions auto-publishes to npm +# 4. Clients can now: npm update @agency/shopify-core +``` + +### Working on Client-Specific Feature (hanro-theme repo) + +```bash +# Clone client repo +git clone github.com/agency/hanro-theme +cd hanro-theme/ + +# Files are at root (Shopify GitHub integration) +ls +# assets/ blocks/ sections/ config/ layout/ ... + +# Create feature branch (JIRA pattern) +git checkout -b feature/HAN-300-custom-hero + +# Create new client-specific section (at root) +cat > sections/hanro-hero.liquid << 'EOF' + +{% schema %} +{ + "name": "Hanro Hero", + "settings": [...] +} +{% endschema %} +EOF + +# Create client-specific CSS (at root) +cat > assets/hanro-hero.css << 'EOF' +/* Custom hero styles */ +.hanro-hero { ... } +EOF + +# OR: Customize existing core file +vim assets/product-card.css +# Add Hanro swatch customizations + +# No build step needed - files already at root! +# Test with Shopify CLI +shopify theme dev --store=hanro-dev.myshopify.com + +# Validate +shopify theme check + +# Commit & push +git add sections/hanro-hero.liquid assets/hanro-hero.css +git commit -m "HAN-300: Add custom video hero section" +git push origin feature/HAN-300-custom-hero + +# Create PR to master +# After merge → Shopify auto-syncs via GitHub integration ✅ +``` + +**Key Changes:** +- Work directly at root (not in `src/`) +- No build step (files already in Shopify structure) +- Push to GitHub → Shopify auto-syncs +- Theme editor changes → bot commits back to Git + +### Updating Client to Latest Core Version + +```bash +cd hanro-theme/ + +# Create update branch +git checkout -b update/core-v1.3.2 + +# Update npm package +npm update @agency/shopify-core +# Updates: 1.0.0 → 1.3.2 + +# Run update script (copies new core files to root) +npm run update-core + +# Review changes +git status +# Will show all core files that were updated + +git diff +# Review what changed in core + +# Commit core update +git add . +git commit -m "Update core theme to v1.3.2" + +# Merge to master +git checkout master +git merge update/core-v1.3.2 + +# If conflicts (client modified files that core also updated): +# Resolve merge conflicts - keep BOTH changes +# (Client customizations + Core updates) + +git add . +git commit -m "Merge core v1.3.2 (resolved conflicts)" + +# Push to master +git push origin master +# Shopify GitHub integration auto-syncs ✅ + +# Test on Shopify +# Visit: https://hanro.myshopify.com/?preview_theme_id=master +``` + +**Conflict Resolution Example:** +```bash +# If assets/product-card.css has conflict: +vim assets/product-card.css + +# Shows: +# <<<< HEAD (client changes) +# .hanro-swatch { custom styles } +# ==== +# .product-card { core bug fix } +# >>>> update/core-v1.3.2 + +# Resolve: Keep BOTH +# .hanro-swatch { custom styles } ← Client +# .product-card { core bug fix } ← Core + +git add assets/product-card.css +# Continue merge +``` + +--- + +## Customization Management (Git-Based) + +### How Customizations Work + +With the install script architecture, client customizations happen directly at the repo root. Git tracks everything: + +**Two types of customizations:** +1. **New client files** (not from core) - e.g., `blocks/_hanro-hero.liquid` +2. **Modified core files** (from core, customized) - e.g., `assets/product-card.css` + +**Git handles all tracking** - no complex manifest system needed! + +### Tracking Client Customizations + +**Use Git to identify customizations** (no manifest needed): +```bash +# See what files client has customized +git log --oneline --all -- assets/product-card.css +# Shows: "HAN-123: Add color swatch functionality" + +# See client-specific files (not from core) +git log --diff-filter=A --oneline -- blocks/_hanro-hero.liquid +# Shows: "HAN-200: Add custom hero block" + +# See which core files were modified +git diff HEAD --name-only +# Lists all files changed since core install +``` + +**Documentation in Git commits** (no separate manifest): +```bash +# When customizing core file: +git commit -m "HAN-123: Customize product card for swatch functionality + +Modified core file: assets/product-card.css +Reason: Add Hanro color swatch system +Notes: Integrates with acdc-sib-swatch.js" + +# When adding new file: +git commit -m "HAN-200: Add custom hero section + +New file: sections/hanro-hero.liquid +Reason: Custom video hero for campaigns" +``` + +**Benefits:** +- ✅ Git history documents WHY each customization exists +- ✅ Git blame shows who made changes +- ✅ No manual tracking system to maintain +- ✅ Standard developer workflow + +### Helper Scripts (Optional) + +**Simple utilities for common tasks**: +**Add to `package.json`**: +```json +{ + "scripts": { + "install-core": "node scripts/install-core.js", + "update-core": "node scripts/update-core.js", + "diff-core": "git diff node_modules/@agency/shopify-core/" + } +} +``` + +### Script: Diff Against Core + +**See what changed compared to core:** +```bash +# Compare a file against current core version +git diff node_modules/@agency/shopify-core/assets/product-card.css assets/product-card.css + +# Shows what client changed compared to core +``` + +### Common Workflows + +**See all client customizations:** +```bash +# List all files changed since initial core install +git diff HEAD --name-only + +# See detailed changes +git diff HEAD +``` + +**Check if file was modified from core:** +```bash +# Compare your version vs core version +diff assets/product-card.css node_modules/@agency/shopify-core/assets/product-card.css + +# If different → client customized +# If same → unchanged from core +``` + +**Document customization in Git:** +```bash +# Always document WHY in commit messages +git commit -m "HAN-123: Customize product card for swatch functionality + +Modified: assets/product-card.css (from core) +Changes: Added Hanro color swatch system +Notes: Integrates with acdc-sib-swatch.js" +``` + +**This approach:** +- ✅ Uses Git (standard developer tool, no custom tracking) +- ✅ Git history shows all customizations +- ✅ Git diff shows client changes vs core +- ✅ Git merge handles core updates + client changes +- ✅ Simple (no manifest files, no custom scripts) + +## Theme Editor Support (Merchants Can Still Customize) + +**The Challenge**: Merchants customize via Shopify admin. How do we preserve edits during core updates? + +**Solution**: Config files (`config/`, `templates/`) are **client-owned**, never overwritten. + +### What Happens During Core Updates + +1. **New sections/blocks** → Available in theme editor automatically +2. **Merchant's settings** → Preserved in `settings_data.json` (untouched) +3. **Breaking changes** → Manual opt-in (major version only) + +### Merchant Experience + +- ✅ Drag/drop sections on pages +- ✅ Edit text, images, colors in editor +- ✅ Changes save to client config (safe from core updates) +- ✅ New core features appear in section library (opt-in) + +--- + +## Risks & Mitigation + +### Risk 1: Merge Conflicts (Client Overrode Core File) + +**Likelihood**: High | **Impact**: Medium + +**Mitigation**: +- Build script logs all overridden files +- CHANGELOG highlights file changes +- Developer reviews warnings quarterly +- Minimize Liquid overrides, prefer CSS/JS + +### Risk 2: Breaking Changes in Core + +**Likelihood**: Low | **Impact**: High + +**Mitigation**: +- Semantic versioning (major = breaking) +- Deprecation warnings (2 releases before removal) +- Migration guides with automated fixes +- Staged rollouts (pilot clients first) + +### Risk 3: Merge Conflicts on Core Updates + +**Likelihood**: Medium | **Impact**: Medium + +**Mitigation**: +- Git merge workflow (standard, familiar to devs) +- Update branches (test before merging to master) +- Clear documentation (how to resolve common conflicts) +- Core update frequency controlled (batch changes, reduce merge frequency) +- CHANGELOG with detailed migration guides + +### Risk 4: Core Update Merge Burden (50 Clients) + +**Likelihood**: Medium | **Impact**: Medium + +**Mitigation**: +- Control core update frequency (monthly vs weekly, batch changes) +- Stratify clients: light customizers (rare conflicts) vs heavy customizers (frequent conflicts) +- Automated testing (GitHub Actions runs theme check on staging) +- Clear merge conflict resolution guides +- Consider: Heavy customizers might fork core (manage their own version) + +--- + +## Critical Files to Implement + +These 5 files form the "backbone" of the NPM package system: + +### Core Theme Repository (shopify-agency-core) + +1. **`shopify-agency-core/package.json`** + NPM package manifest. Defines package name, version, files to publish, and npm registry config. + ```json + { + "name": "@agency/shopify-core", + "version": "1.0.0", + "files": ["assets/**", "blocks/**", "sections/**", ...], + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } + } + ``` + +2. **`shopify-agency-core/.github/workflows/publish-to-npm.yml`** + GitHub Actions workflow that auto-publishes to npm on Git tag. + ```yaml + on: + push: + tags: ['v*'] + jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: npm publish + ``` + +3. **`shopify-agency-core/CORE.md`** + Core development guide. Documents what belongs in core vs. clients, contribution guidelines. + +### Client Theme Repository (hanro-theme) + +4. **`hanro-theme/scripts/install-core.js`** + Install script that copies core files from node_modules to repo root. + Initial setup for new client projects. + +5. **`hanro-theme/scripts/update-core.js`** + Update script that copies new core version to root for core updates. + Handles core version updates. + +6. **`hanro-theme/.core-version`** + Simple text file tracking installed core version (e.g., "1.2.0"). + +### Bonus: Auto-update Workflow + +7. **`hanro-theme/.github/workflows/auto-update-core.yml`** + Optional automated core update workflow. Runs update-core script and commits to staging branch for review. + +--- + +## Success Metrics + +Track post-migration: + +- **Development Velocity**: Time to ship core feature to all clients < 1 week +- **Code Duplication**: Lines of duplicated code < 5% +- **Deployment Frequency**: Core updates 1-2x/month (controlled) +- **Update Success Rate**: Core updates merge cleanly > 80% of time +- **GitHub Integration**: Master → Shopify sync working 100% +- **Performance**: Lighthouse scores ≥ 85 (all clients) +- **Developer Satisfaction**: "NPM package + install script system makes job easier" > 80% agree + +--- + +## Architecture Benefits + +✅ **Shopify GitHub Integration**: Master branch auto-syncs to live theme +✅ **Theme Editor Sync**: Merchant changes commit back to Git automatically +✅ **Continuous Updates**: Core releases via npm, clients update via install script +✅ **Customization Freedom**: Clients modify any file, Git merge preserves both changes +✅ **Merchant Control**: Full section library (94 blocks) available in theme editor +✅ **Developer Velocity**: Shared improvements in core, reduced duplication +✅ **Standard Workflow**: Git merge for updates (familiar to all developers) +✅ **Future-Proof**: Skeleton baseline (stable) + Horizon features (modern) + +--- + +## Questions & Clarifications + +**Q: What if a client needs a feature that doesn't fit the core theme?** +A: Build it directly in their repo (e.g., `sections/hanro-hero.liquid`). Core stays generic, client gets custom feature without affecting other clients. Client files are committed to their Git repo alongside core files. + +**Q: How do we handle third-party app integrations (Klaviyo, Intelligems)?** +A: Client-specific integrations go in their repo (JS files, Liquid snippets at root). If multiple clients need same integration, consider adding to core with feature flag. + +**Q: Can clients stay on older core versions?** +A: Yes. `package.json` specifies core version with semver range (e.g., `^1.2.0`). Clients control when to update. Major versions require manual upgrade. + +**Q: What if we need to unpublish the npm package?** +A: npm allows unpublish within 72 hours. After that, publish a new version with fixes. Use private registry (GitHub Packages) for more control. + +**Q: What happens if update script fails?** +A: Update branch shows errors, doesn't merge to master. Developer fixes issue, retries. Master branch (and live theme) unaffected. Can discard update branch and retry. + +**Q: How do we train team on this workflow?** +A: Create `CONTRIBUTING.md` with examples, run workshop session, pair programming for first features, document in Confluence. + +**Q: How do we get Horizon updates from Shopify?** +A: We don't track Horizon as upstream. Core theme is forked from Skeleton (structure) with all Horizon features copied once. If Horizon releases critical security fixes, we manually port them. We prioritize independence over automatic updates. + +**Q: What if a Horizon update breaks our core theme?** +A: Review in separate branch first (`review/horizon-v3.3.0`), test thoroughly before merging to main. If breaking, don't merge - wait for Shopify fix or adapt our customizations. + +--- + + +## Critical Decisions Before Starting + +These decisions must be made before Week 1 begins: + +### 1. GitHub Packages Authentication + +**Decision**: ✅ **Individual PATs with Role-Based Permissions** (finalized) + +Each of the 5 developers generates their own Personal Access Token (PAT) with role-appropriate permissions. + +**Permission Structure**: + +**All 5 Developers** - Read Access: +- **Scopes**: `read:packages`, `repo` +- **Can**: Install core theme (`npm install @agency/shopify-core`) +- **Can**: Write code, create PRs, contribute features (Git access) +- **Cannot**: Publish new versions to npm + +**1-2 Senior Developers** - Write Access (Release Managers): +- **Scopes**: `read:packages`, `write:packages`, `repo` +- **Can**: Everything above PLUS publish releases (`npm publish`) +- **Responsible for**: Version bumps, changelogs, release timing + +**Why This Approach**: +- ✅ Everyone can contribute code (Git permissions separate from npm publish) +- ✅ Only seniors release packages (prevents accidental publishes) +- ✅ Audit trail (know who published which version) +- ✅ Security (individual tokens, revoke individually) +- ✅ No shared credentials (each dev has their own PAT) + +**Setup Steps** (Per Developer, 5 mins one-time): +```bash +# 1. Generate PAT on GitHub +# Go to: GitHub Settings → Developer Settings → Personal Access Tokens → Generate new token +# Scopes: +# - read:packages (all developers) +# - write:packages (only release managers: 1-2 senior devs) +# - repo (all developers) +# Copy token (only shown once!) + +# 2. Create .npmrc in home directory (~/.npmrc) +echo "@agency:registry=https://npm.pkg.github.com" >> ~/.npmrc +echo "//npm.pkg.github.com/:_authToken=ghp_YOUR_TOKEN_HERE" >> ~/.npmrc + +# 3. Test authentication +npm whoami --registry=https://npm.pkg.github.com +# Should show your GitHub username +``` + +**For CI/CD** (GitHub Actions): +```yaml +# Uses built-in GITHUB_TOKEN (has write:packages automatically in workflows) +- name: Setup npm auth + run: | + echo "@agency:registry=https://npm.pkg.github.com" >> .npmrc + echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc +``` + +**Security Best Practices**: +- ✅ Store PAT in `~/.npmrc` (never commit to Git) +- ✅ Add `.npmrc` to global `.gitignore` +- ✅ Rotate PATs every 6-12 months +- ✅ Revoke PAT immediately when developer leaves +- ✅ Use fine-grained PATs (limit to specific repos) + +--- + +### 2. Client Development/Staging Strategy (General) + +**Question**: How to test client theme rebuilds without affecting live stores? + +**Shopify Approach** (No Traditional Staging): +Shopify doesn't have "staging servers." Instead, use **unpublished themes** on the live store. + +**How Unpublished Themes Work**: +```bash +# Shopify allows up to 20 themes per store: +# - 1 "Live" theme (published, what customers see) +# - 19 "Unpublished" themes (preview-only) + +# Push client rebuild as unpublished theme +cd client-theme/build/ +shopify theme push --unpublished --theme="Theme Rebuild - Dev" + +# Get preview URL +shopify theme list +# Output shows preview URL with theme ID parameter + +# Preview URL example: https://store.myshopify.com/?preview_theme_id=123456 +``` + +**Workflow** (Recommended for All Clients): +1. **Developer testing**: Use `shopify theme dev` (live reload on localhost) +2. **Team QA**: Push as unpublished theme → share preview URL +3. **Client review**: Client tests via preview URL (real data, not customer-facing) +4. **Go live**: Publish theme → becomes live + +**What This Means**: +- ✅ Uses real store data (products, orders, customers) - accurate testing +- ✅ NOT visible to customers (requires preview URL parameter) +- ✅ Can have multiple unpublished themes (dev, staging, QA) +- ⚠️ Shares same data (changes to products affect all themes) +- ⚠️ Can't test checkout without real orders (use test mode) + +**Recommendation**: Unpublished themes sufficient for client rebuilds - no separate staging server needed + +**Needs confirmation**: Team comfortable with this workflow? + +--- + +### 3. Initial Core Theme State + +**Decision**: ✅ **Vanilla Copy** (finalized) + +Core theme v1.0.0 will be: **Skeleton structure + Horizon features (copied as-is, zero modifications)** + +### Why Vanilla Copy +- ✅ **Fast publish** (2-3 days vs 5-7 days) +- ✅ **Full feature set** immediately (all 94 Horizon blocks) +- ✅ **Clean baseline** for future customizations +- ✅ **Learn from Hanro** what agency features are actually needed +- ✅ **Iterate based on real needs** (not speculation) + +### What This Means + +**v1.0.0** (Week 1): +- Skeleton structure (layout, templates) +- All 94 Horizon blocks (unmodified) +- All 41 Horizon sections (unmodified) +- All Horizon assets, snippets, configs (unmodified) +- Published to npm immediately + +**v1.1.0+** (Weeks 6+, after Hanro learnings): +- Add: Gift wrap block (learned from Hanro) +- Add: Custom variant picker (learned from Hanro) +- Add: Agency-specific enhancements +- Iterate based on real client needs + +### Implementation Timeline +- **Day 1-2**: Fork Skeleton, copy Horizon, publish v1.0.0 (vanilla) +- **Week 2-6**: Hanro migration (uses vanilla v1.0.0 as base) +- **Week 6+**: Extract proven features from Hanro to core (v1.1.0, v1.2.0) + +--- + +## Core Theme Specific Prerequisites + +These decisions and validations are specific to setting up the core theme (before Hanro migration begins): + +### 1. Skeleton + Horizon Copy Process (Day 0 - Before Starting) + +**Action**: Understand both Skeleton and Horizon structures before copying + +**Step 1: Fork Skeleton** +```bash +# On GitHub: Fork Shopify/skeleton to agency/shopify-agency-core +# Clone your fork +git clone github.com/agency/shopify-agency-core +cd shopify-agency-core + +# Add Skeleton as upstream +git remote add upstream https://github.com/Shopify/skeleton.git +git fetch upstream + +# Examine Skeleton structure +ls -la +# Expected: Basic structure only (layout/, templates/, minimal sections/) +``` + +**Step 2: Clone Horizon for Copying** +```bash +# Clone Horizon to separate directory (use main branch) +git clone https://github.com/Shopify/horizon.git /tmp/horizon-copy-source +cd /tmp/horizon-copy-source + +# Record exact commit for provenance +git rev-parse HEAD +# Example output: 2ceba3a06fcac943eca631510e58ec1c96f88a39 +# Save this SHA for HORIZON_VERSION.md + +# Examine structure +tree -L 2 +# Note: blocks/, sections/, assets/, snippets/, config/ + +# Check for dependencies +cat package.json +# Note any dependencies we need to handle + +# Identify demo/placeholder content +grep -r "example\|demo\|placeholder" . +# Any dummy data to exclude from copy? +``` + +**Step 3: Copy Horizon into Skeleton Fork** +```bash +cd /path/to/shopify-agency-core + +# Copy Horizon features (preserving Skeleton structure) +cp -r /tmp/horizon-copy-source/blocks/ ./blocks/ +cp -r /tmp/horizon-copy-source/sections/* ./sections/ +cp -r /tmp/horizon-copy-source/assets/* ./assets/ +cp -r /tmp/horizon-copy-source/snippets/* ./snippets/ + +# Merge config files (Horizon + Skeleton settings) +# Manual review: Keep Skeleton's base, add Horizon's settings + +# Commit with provenance +git add . +git commit -m "Copy Horizon v3.2.1 features into Skeleton base + +Copied from: https://github.com/Shopify/horizon +Commit: [Horizon commit SHA] +Date: January 2026 + +Copied: +- All 94 blocks +- All 41 sections +- Assets, snippets, configs + +Preserved Skeleton: +- Layout structure +- Template structure +- Base theme.liquid +" +``` + +**Decision needed**: Which Horizon files to exclude (demos, examples)? + +--- + +### 2. npm Package Configuration (Critical) + +**Decision**: What files to publish in npm package? + +**Recommended package.json**: +```json +{ + "name": "@agency/shopify-core", + "version": "1.0.0", + "description": "Agency core Shopify theme based on Horizon", + "repository": "github.com/agency/shopify-agency-core", + "license": "MIT", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "files": [ + "assets/**", + "blocks/**", + "sections/**", + "snippets/**", + "templates/**", + "config/**", + "layout/**", + "locales/**", + "!**/.DS_Store", + "!**/node_modules" + ], + "keywords": ["shopify", "theme", "horizon"], + "engines": { + "node": ">=18.0.0" + } +} +``` + +**Explicitly EXCLUDE**: +- ❌ `.git/` directory +- ❌ Horizon's original `README.md` (create your own) +- ❌ Horizon's `package.json` (replaced with yours) +- ❌ Any `node_modules/` (if Horizon has dependencies) +- ❌ `.github/` workflows from Horizon (use your own) + +**Create YOUR files**: +- ✅ `README.md` - Your core theme documentation +- ✅ `CORE.md` - Development guidelines +- ✅ `CHANGELOG.md` - Version history +- ✅ `package.json` - Your npm package manifest + +**Needs decision**: Confirm file inclusion list + +--- + +### 3. Test npm Package Install BEFORE Client (Day 2-3) + +**Critical Test**: Verify Shopify theme works when installed via npm + +**Test Sequence**: +```bash +# Day 2: After forking Skeleton and copying Horizon + +# 1. Test local npm pack +npm pack +# Creates: agency-shopify-core-1.0.0.tgz + +# 2. Test install in temp directory +mkdir /tmp/test-npm-theme +cd /tmp/test-npm-theme +npm install /path/to/agency-shopify-core-1.0.0.tgz + +# 3. Verify structure +ls -la node_modules/@agency/shopify-core/ +# Should show: assets/, blocks/, sections/, etc. +# Should NOT show: .git/, original README.md, etc. + +# 4. Test Shopify CLI recognizes it +cd node_modules/@agency/shopify-core/ +shopify theme check +# Should pass: "✓ Valid theme" + +# 5. Test deploy to dev store +shopify theme push --unpublished --store=test.myshopify.com +# Should work: Theme uploads successfully + +# 6. Test in browser +# Visit preview URL - does Horizon theme work? +``` + +**If any step fails**: Fix before publishing to npm registry or creating Hanro client + +**Needs validation**: Complete this test sequence Day 2-3 + +--- + +### 4. Horizon Upstream Remote Setup (Day 1) + +**Action**: Configure Git remotes correctly from start + +**Setup**: +```bash +# After forking Skeleton on GitHub + +# Clone YOUR fork +git clone https://github.com/agency/shopify-agency-core.git +cd shopify-agency-core/ + +# Check current remotes +git remote -v +# origin: github.com/agency/shopify-agency-core (your fork) + +# Add Skeleton as upstream +git remote add upstream https://github.com/Shopify/skeleton-theme.git +git fetch upstream + +# Clone Horizon for copying +git clone https://github.com/Shopify/horizon.git /tmp/horizon-copy-source + +# Copy Horizon features +cp -r /tmp/horizon-copy-source/blocks/ ./blocks/ +cp -r /tmp/horizon-copy-source/sections/* ./sections/ +cp -r /tmp/horizon-copy-source/assets/* ./assets/ +cp -r /tmp/horizon-copy-source/snippets/* ./snippets/ + +# Document source versions for provenance +SKELETON_SHA=$(git rev-parse HEAD) +HORIZON_SHA=$(cd /tmp/horizon-copy-source && git rev-parse HEAD) + +cat > HORIZON_VERSION.md << EOF +# Core Theme Source Documentation + +Created: $(date) + +## Base Structure (Skeleton) +- Repository: https://github.com/Shopify/skeleton-theme +- Branch: main +- Commit: $SKELETON_SHA + +## Features (Horizon) +- Repository: https://github.com/Shopify/horizon +- Branch: main +- Commit: $HORIZON_SHA + +## Components Copied from Horizon +- All 94 blocks (blocks/) +- All 41 sections (sections/) +- All assets (assets/) +- All snippets (snippets/) +- Templates, config, locales + +## Notes +Neither Skeleton nor Horizon use semantic versioning (both use main branch). +This file documents exact commit SHAs for provenance and future reference. +EOF + +# Verify +git remote -v +# origin: github.com/agency/shopify-agency-core (fetch/push) +# upstream: github.com/Shopify/skeleton-theme (fetch only) + +# Tag starting point +git tag v1.0.0 +git push origin v1.0.0 +``` + +**Critical**: Set up Skeleton upstream and copy Horizon on Day 1 + +**Needs validation**: Verify setup before continuing + +--- + +### 5. Skeleton + Horizon License Compliance (Legal) + +**Question**: Both Skeleton and Horizon are MIT licensed - can you fork Skeleton and copy Horizon code? + +**Check Both LICENSE Files**: +- **Skeleton**: MIT License (allows forking and redistribution) +- **Horizon**: MIT License (allows copying and redistribution) +- Must retain original copyright notices for both +- Must include copies of both licenses + +**Recommendation**: +```bash +# In your core theme: +# Keep both LICENSE files + +# LICENSE-SKELETON +# [Skeleton's original MIT license] + +# LICENSE-HORIZON +# [Horizon's original MIT license] + +# LICENSE (Your Core Theme) +# "Based on Shopify's Skeleton theme (structure) and Horizon theme (features)" +# "Both licensed under MIT - see LICENSE-SKELETON and LICENSE-HORIZON" +# [Your agency's copyright for customizations] +``` + +**Needs decision**: Legal review? (MIT is very permissive - dual attribution likely sufficient) + +--- + +### 6. Core Theme Version Strategy + +**Decision**: ✅ **Semantic Versioning (SemVer)** (finalized) + +Core theme versions are independent from both Skeleton and Horizon, following standard Semantic Versioning: `MAJOR.MINOR.PATCH` + +### Version Format + +**MAJOR.MINOR.PATCH** (e.g., `v1.2.3`): +- **MAJOR** (`v2.0.0`): Breaking changes, removed features, requires manual client upgrade +- **MINOR** (`v1.2.0`): New features, backward compatible, clients auto-update with `^` +- **PATCH** (`v1.2.3`): Bug fixes, performance improvements, clients auto-update with `^` + +### Version Examples + +- `v1.0.0` = Initial release (Skeleton + Horizon v3.2.1 copy, no modifications) +- `v1.1.0` = Added gift wrap block (new feature, minor bump) +- `v1.1.1` = Fixed cart bug (bug fix, patch bump) +- `v1.2.0` = Added testimonials block (new feature, minor bump) +- `v2.0.0` = Removed 20 unused blocks (breaking change, major bump) + +### Client Update Behavior + +**Client package.json:** +```json +{ + "dependencies": { + "@agency/shopify-core": "^1.0.0" + } +} +``` + +**With `^1.0.0` (caret range):** +- ✅ Auto-updates: `v1.1.0`, `v1.2.0`, `v1.999.0` (minor/patch) +- ❌ Does NOT auto-update: `v2.0.0` (major/breaking) +- Clients must manually upgrade to v2.x.x + +### Release Process + +**Feature Release** (Minor): +```bash +# Add new feature +git commit -m "feat: add testimonials block" + +# Bump version +npm version minor # 1.0.0 → 1.1.0 + +# Push with tags +git push && git push --tags + +# GitHub Actions publishes automatically +``` + +**Bug Fix** (Patch): +```bash +git commit -m "fix: cart drawer closing issue" +npm version patch # 1.1.0 → 1.1.1 +git push && git push --tags +``` + +**Breaking Change** (Major): +```bash +git commit -m "BREAKING: removed unused blocks (see PRUNING_LOG.md)" +npm version major # 1.5.0 → 2.0.0 +git push && git push --tags +# Clients stay on v1.x.x, must manually upgrade +``` + +### Documentation Format + +**CHANGELOG.md:** +```markdown +## v1.2.0 - 2026-02-15 + +### Added +- Testimonials block with rating display +- Gift wrap option in cart drawer + +### Changed +- Enhanced product card with hover effects +- Improved mobile navigation performance + +### Fixed +- Cart drawer not closing on mobile +- Product gallery thumbnail alignment + +### Horizon Reference +- Based on: Horizon v3.2.1 (no changes) +- Manually ported: Horizon security fix #456 +``` + +**HORIZON_VERSION.md:** +```markdown +# Core Theme Version History + +## v1.0.0 (January 2026) +- Base: Skeleton (commit abc123) +- Features: Horizon v3.2.1 (commit def456) +- Status: Full copy, zero modifications + +## v1.2.0 (February 2026) +- Added: Gift wrap block, testimonials block +- Modified: Cart drawer (free shipping bar) +- Horizon: No updates (still v3.2.1) + +## v2.0.0 (January 2027) +- BREAKING: Removed 20 unused blocks +- Modified: Product card (major refactor) +- Horizon: Manually ported security fixes +``` + +### Why Semantic Versioning +- ✅ npm ecosystem standard +- ✅ Clients understand update safety (`^` range) +- ✅ Clear communication (major = breaking) +- ✅ Tooling support (`npm version`, `npm outdated`) +- ✅ Industry best practice + +--- + +### 7. npm Package File Selection (Skeleton + Horizon) + +**Decision**: ✅ **Finalized** + +Using `"files"` whitelist in package.json for explicit control. + +**package.json configuration:** +```json +{ + "name": "@agency/shopify-core", + "version": "1.0.0", + "files": [ + "assets/**", + "blocks/**", + "sections/**", + "snippets/**", + "templates/**", + "config/**", + "layout/**", + "locales/**", + "HORIZON_VERSION.md", + "LICENSE-SKELETON", + "LICENSE-HORIZON", + "PRUNING_STRATEGY.md", + "README.md", + "!**/.DS_Store", + "!**/node_modules", + "!**/.git", + "!**/.github" + ] +} +``` + +**What gets published to npm:** +- ✅ All theme directories (complete Shopify theme structure) +- ✅ Documentation (HORIZON_VERSION.md, licenses, pruning strategy) +- ✅ README.md (agency-created npm package documentation) +- ❌ package.json (npm includes automatically, stays in node_modules) +- ❌ .github/ workflows (client repos have their own) +- ❌ Original Skeleton/Horizon READMEs (replaced with ours) + +**What clients get when installing:** +``` +node_modules/@agency/shopify-core/ + assets/ + blocks/ + sections/ + snippets/ + templates/ + config/ + layout/ + locales/ + HORIZON_VERSION.md + LICENSE-SKELETON + LICENSE-HORIZON + PRUNING_STRATEGY.md + README.md + package.json ← npm metadata (not copied to client root) +``` + +**install-core.js copies everything EXCEPT package.json** to client root + +--- + +### 8. npm Package Test Before Publishing + +**Critical**: Test `npm pack` → `npm install` → Shopify deploy sequence BEFORE publishing to registry + +**Test Workflow** (Day 2-3): +```bash +# In shopify-agency-core/ + +# 1. Create test package +npm pack +# Output: agency-shopify-core-1.0.0.tgz + +# 2. Install in temp directory +mkdir /tmp/test-core-install +cd /tmp/test-core-install +npm init -y +npm install /path/to/shopify-agency-core/agency-shopify-core-1.0.0.tgz + +# 3. Verify theme structure intact +ls -la node_modules/@agency/shopify-core/ +# Should show: assets/, blocks/, sections/, config/, layout/, locales/, templates/, snippets/ + +# 4. Verify Shopify CLI recognizes theme +cd node_modules/@agency/shopify-core/ +shopify theme check +# Expected: ✓ Theme valid + +# 5. Test deploy to Shopify dev store +shopify theme push --unpublished --store=core-test.myshopify.com +# Expected: Theme uploads successfully + +# 6. Test in browser +# Visit preview URL +# Expected: Horizon theme renders correctly +``` + +**If ANY step fails**: +- Fix package.json "files" configuration +- Fix file structure issues +- Re-test before publishing to npm registry + +**Why this matters**: If npm package is broken, ALL client repos will fail `npm install` + +**Needs validation**: Complete test before Day 4 + +--- + +### 9. GitHub Actions npm Publish Configuration + +**Critical**: GitHub Actions needs proper authentication and permissions + +**Setup Required**: + +**Step 1**: Enable GitHub Actions in repository settings +- Repo Settings → Actions → Allow Actions + +**Step 2**: Grant workflow write permissions +- Repo Settings → Actions → Workflow permissions → "Read and write permissions" + +**Step 3**: Create publish workflow +```yaml +# .github/workflows/publish-to-npm.yml +name: Publish to GitHub Packages + +on: + push: + tags: + - 'v*' # Triggers on version tags (v1.0.0, v1.1.0, etc.) + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + registry-url: 'https://npm.pkg.github.com' + scope: '@agency' + + - name: Publish to GitHub Packages + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +**Step 4**: Test workflow +```bash +# Create test tag +git tag v1.0.0-test +git push origin v1.0.0-test + +# Check GitHub Actions tab +# Should see: "Publish to GitHub Packages" workflow running +# Should succeed: Package published + +# Cleanup test +git tag -d v1.0.0-test +git push origin :refs/tags/v1.0.0-test +npm unpublish @agency/shopify-core@1.0.0-test +``` + +**Needs validation**: Test GitHub Actions workflow on Day 3 before real v1.0.0 publish + +--- + +### 10. Core Theme Development Store (Optional) + +**Question**: Do you need `core-development.myshopify.com` for testing core theme changes? + +**Analysis**: +- Core theme is Horizon (works standalone on any Shopify store) +- Core changes get tested when clients rebuild (Hanro dev theme) +- Separate dev store adds overhead (manage another store) + +**Recommendation**: **Not necessary** - Test core changes on Hanro's dev theme + +**Alternative**: If you want isolation, create dev store via Shopify Partner account (free), but adds complexity + +**Needs decision**: Skip core dev store? Or create one? + +--- + +## Summary: Critical Path to Day 1 + +**Pre-Day 1** (This Week): +1. ✅ **Examine Horizon repo** - Understand structure, dependencies, files +2. ✅ **Make Critical Decisions** (listed above) - Repo strategy, versioning, file inclusion +3. ✅ **Setup GitHub Packages** - PAT tokens, authentication +4. ✅ **Confirm Shopify access** - Partner account, can create dev stores + +**Day 1** (Can Start): +5. ✅ **Fork Skeleton + Copy Horizon** - Setup upstream remote, copy Horizon features, tag starting point +6. ✅ **Create package.json** - File inclusion list, npm config +7. ✅ **Create documentation** - README, CORE.md, CHANGELOG + +**Day 2-3** (Critical Testing): +8. ✅ **Test npm pack/install** - Verify theme works when installed via npm +9. ✅ **Test Shopify deploy** - Verify theme deploys from node_modules +10. ✅ **Test GitHub Actions** - Verify automated publish works + +**Day 4** (Publish for Real): +11. ✅ **Tag v1.0.0** - Trigger real publish +12. ✅ **Verify published** - Check GitHub Packages, test install + +**Day 5** (Hanro Setup): +13. ✅ **Create Hanro client repo** - Install core via install script, test Shopify GitHub sync + +--- + +**Verified Repositories**: +- **Skeleton**: [github.com/Shopify/skeleton-theme](https://github.com/Shopify/skeleton-theme) (MIT License) + - No version tags (uses main branch) + - 38 commits + - Latest commit: `04069e0` (as of Jan 2026) + +- **Horizon**: [github.com/Shopify/horizon](https://github.com/Shopify/horizon) (MIT License) + - No version tags (uses main branch) + - 34 commits + - Latest commit: `2ceba3a` (as of Jan 2026) + - Created: July 2025 + +**Decision**: ✅ **Fork from main branch, document commit SHA** +- Both repos use continuous development (no semantic versions) +- Fork from main, record exact commit SHA in HORIZON_VERSION.md +- Example: "Based on Skeleton main@04069e0 + Horizon main@2ceba3a" + +--- + +## Pre-Implementation Checklist + +### Gap 7: .npmignore vs package.json "files" ⚠️ + +**The Problem**: Two ways to control what gets published to npm: + +**Option A**: Use `"files"` in package.json (whitelist) +```json +{ + "files": ["assets/**", "blocks/**", ...] // Only these included +} +``` + +**Option B**: Use `.npmignore` (blacklist) +``` +# .npmignore +.git +.github +*.md +node_modules +``` + +**Recommendation**: Use `"files"` (whitelist) - More explicit, safer + +**But**: Plan doesn't show this clearly + +--- + +### Gap 8: GitHub Actions Secrets for npm Publish ⚠️ BLOCKER + +**The Problem**: GitHub Actions needs PAT to publish to GitHub Packages + +**Current plan shows**: +```yaml +# .github/workflows/publish-to-npm.yml +- run: npm publish +``` + +**Missing**: How does GitHub Actions authenticate? + +**Solution**: +```yaml +# .github/workflows/publish-to-npm.yml +name: Publish to npm + +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + registry-url: 'https://npm.pkg.github.com' + scope: '@agency' + + - name: Configure npm auth + run: | + echo "@agency:registry=https://npm.pkg.github.com" >> .npmrc + echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc + + - name: Publish package + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +**Critical**: `GITHUB_TOKEN` is auto-provided by GitHub Actions (no manual setup needed) + +**But**: Need to grant GitHub Actions workflow `write:packages` permission in repo settings + +**Needs action**: Configure repo permissions for GitHub Actions + +--- + +### Gap 9: What if npm Publish Fails? ⚠️ + +**Scenario**: You tag v1.0.0, GitHub Actions tries to publish, fails + +**Current plan doesn't address**: +- Can you retry? (Yes, but must delete tag and recreate) +- Does failed publish leave broken package? (No, nothing published) +- How do you debug? + +**Recommendation - Add to plan**: +```bash +# If npm publish fails: + +# 1. Check GitHub Actions logs for error + +# 2. Fix issue locally + +# 3. Delete failed tag +git tag -d v1.0.0 +git push origin :refs/tags/v1.0.0 + +# 4. Re-tag and push +git tag v1.0.0 +git push origin v1.0.0 + +# 5. GitHub Actions runs again +``` + +--- + +### Gap 10: Horizon Repository Location ⚠️ ASSUMPTION + +**The Problem**: Plan assumes `https://github.com/Shopify/horizon.git` + +**But**: Is this the official Horizon repo? + +Let me verify: + + + +Shopify Horizon theme official GitHub repository URL + +Before starting Week 1, ensure these prerequisites are in place: + +### Infrastructure & Access (Must Have Before Day 1) + +**GitHub Organization**: +- [ ] GitHub organization exists +- [ ] Team members added with appropriate permissions +- [ ] Can create new repositories + +**Shopify Partner Account**: +- [ ] Shopify Partner account active +- [ ] Can create development stores (for testing core theme) +- [ ] Shopify CLI installed on developer machines: `npm install -g @shopify/cli` +- [ ] Shopify CLI authenticated: `shopify auth login` +- [ ] Can create development stores via Shopify Partner account (for testing core theme) + +--- + +### Team Readiness + +**Developer Environment**: +- [ ] Node.js v18+ installed on all machines +- [ ] npm and Git configured + +**Communication**: +- [ ] Project communication channel (Slack/Teams) +- [ ] Use existing HAN JIRA project for tracking + +--- + +## Immediate Next Steps + +### Core System Setup (Week 1) + +1. **Day 1-2: Initialize Core Theme Repository** + - Fork Skeleton theme on GitHub (github.com/agency/shopify-agency-core forked from Shopify/skeleton) + - Clone your fork locally + - Add Skeleton as upstream remote: `git remote add upstream https://github.com/Shopify/skeleton-theme.git` + - Clone Horizon to separate directory for copying: `/tmp/horizon-copy-source` + - Copy ALL Horizon features into Skeleton fork (blocks, sections, assets, snippets) + - Document Horizon version in `HORIZON_VERSION.md` + - Tag starting point: `git tag v1.0.0` + - Setup package.json for npm package + - Choose npm registry (GitHub Packages recommended) + - Publish v1.0.0 to npm + + **Result**: Core theme = Skeleton structure + Horizon features (full copy), published as npm package + +2. **Day 3: Setup CI/CD** + - Create `.github/workflows/publish-to-npm.yml` + - Test automated publishing (tag → npm publish) + - Create CHANGELOG.md and CORE.md documentation + +3. **Day 4: Test with Dummy Client** + - Create test client repo + - Install `@agency/shopify-core` + - Create install-core.js and update-core.js scripts + - Test install script → files at root + - Connect to Shopify via GitHub integration + - Validate master branch → Shopify auto-sync works + +4. **Day 5: Finalize Documentation** + - Write README for core theme + - Write CONTRIBUTING.md + - Document override management system + - Train team on new workflow + +### Next: Client Migration + +See `hanro-migration.md` for detailed Hanro migration plan. + +--- + +## Verification Plan + +After implementing the core theme system, verify: + +### NPM Package System +- [ ] Core theme publishes to npm registry successfully +- [ ] Test client repo installs `@agency/shopify-core` via npm +- [ ] `npm run install-core` copies core files to root +- [ ] Theme passes `shopify theme check` at root +- [ ] Theme files at root work with Shopify GitHub integration +- [ ] Master branch → Shopify auto-syncs + +### Core Updates Workflow +- [ ] Make change to core theme repo +- [ ] Update package.json version + git tag +- [ ] GitHub Actions auto-publishes to npm +- [ ] Client runs `npm update @agency/shopify-core` +- [ ] Client rebuilds with new core version +- [ ] New core features available in theme editor + +### Skeleton Upstream Tracking + Horizon Reference +- [ ] Core theme has upstream remote pointing to Shopify/skeleton-theme +- [ ] Can fetch Skeleton updates: `git fetch upstream` (rarely needed) +- [ ] Horizon version documented in `HORIZON_VERSION.md` +- [ ] Can reference Horizon for security fixes (manual port) +- [ ] Tag tracks core version: `v1.5.0` + +### Client Customizations +- [ ] Create client override file (e.g., `src/assets/custom.css`) +- [ ] Build includes override file +- [ ] Core file NOT overwritten by client file (precedence correct) +- [ ] Theme editor still functional +- [ ] Merchant can customize via theme editor + +### Customization Management (Git-Based) +- [ ] `npm run install-core` copies core files to root +- [ ] `npm run update-core` updates core files (overwrites all) +- [ ] Git diff shows client customizations vs core +- [ ] Git merge handles core updates + client changes +- [ ] `.core-version` file tracks installed core version + +### Documentation +- [ ] README.md explains npm package usage +- [ ] CORE.md documents what belongs in core +- [ ] CONTRIBUTING.md has developer workflows +- [ ] CHANGELOG.md tracks version history +- [ ] Example client repo demonstrates usage + +--- + +**Next Steps**: See `hanro-migration.md` for client migration plan. + +--- + +**End of Plan** diff --git a/.planning/EXECUTION-PLAN.md b/.planning/EXECUTION-PLAN.md new file mode 100644 index 000000000..6b8d75c55 --- /dev/null +++ b/.planning/EXECUTION-PLAN.md @@ -0,0 +1,768 @@ +# Core Theme System - Implementation Execution Plan + +**Project**: Build core Shopify theme as NPM package for multi-client deployment +**Base**: Skeleton (structure) + Horizon (features, full copy) +**Architecture**: Install script pattern (core files copied to client repo root) +**Registry**: GitHub Packages (private npm) + +--- + +## Executive Summary + +Core theme published as npm package (`@agency/shopify-core`). Client repos install via npm, then run install script to copy theme files to root (required for Shopify GitHub integration). Updates via `npm update` + update script + Git merge. + +**Key Constraints**: +- ✅ Theme files MUST be at repo root (Shopify GitHub integration requirement) +- ✅ Master branch auto-syncs to Shopify (no build/ directory allowed) +- ✅ Theme editor commits back to Git (two-way sync) + +**Architecture**: Install script copies core → root. Updates via Git merge (preserves client customizations). + +--- + +## Plan Approach + +This plan uses a **hybrid approach** for implementation details: + +**Exact commands** (copy/paste ready): +- Standard git operations (`git clone`, `git tag`, `git commit`) +- Directory operations (`cp`, `mkdir`) +- npm commands (`npm install`, `npm version`) +- Shopify CLI commands (`shopify theme check`) + +**Requirements + pseudocode** (adapt during execution): +- Script implementations (install-core.js, update-core.js) +- Configuration files (package.json examples provided as reference) +- Documentation content (requirements listed, not exact text) + +**Why**: Standard operations are well-tested and unlikely to need changes. Complex implementations may need refinement when you encounter real context during execution. Requirements ensure you capture the right functionality while allowing flexibility in implementation. + +--- + +## Day-by-Day Execution Plan + +### Prerequisites (Before Day 1) +- [ ] GitHub org exists, team has access +- [ ] Shopify Partner account active +- [ ] Shopify CLI installed: `npm install -g @shopify/cli` +- [ ] Node.js 18+ on all machines +- [ ] Generate GitHub PAT: Settings → Developer Settings → Personal Access Tokens → `read:packages`, `repo` +- [ ] Configure npm auth (each developer): + ```bash + echo "@agency:registry=https://npm.pkg.github.com" >> ~/.npmrc + echo "//npm.pkg.github.com/:_authToken=YOUR_PAT_HERE" >> ~/.npmrc + ``` + +--- + +### Day 1: Initialize Core Repository + +**Goal**: Fork Skeleton + copy Horizon features + +```bash +# 1. Fork Skeleton on GitHub +# Go to: https://github.com/Shopify/skeleton-theme +# Click "Fork" → Create fork in your org: github.com/agency/shopify-agency-core + +# 2. Clone YOUR fork +git clone https://github.com/agency/shopify-agency-core.git +cd shopify-agency-core/ + +# 3. Add Skeleton as upstream +git remote add upstream https://github.com/Shopify/skeleton-theme.git +git fetch upstream + +# 4. Clone Horizon for copying +cd /tmp +git clone https://github.com/Shopify/horizon.git horizon-copy-source +cd horizon-copy-source +HORIZON_SHA=$(git rev-parse HEAD) +echo "Horizon commit: $HORIZON_SHA" # Save this + +# 5. Copy Horizon features to your core repo +cd ~/shopify-agency-core/ # Adjust path +cp -r /tmp/horizon-copy-source/blocks ./blocks +cp -r /tmp/horizon-copy-source/sections/* ./sections/ +cp -r /tmp/horizon-copy-source/assets/* ./assets/ +cp -r /tmp/horizon-copy-source/snippets/* ./snippets/ +# Review config/ - merge Horizon settings with Skeleton base (manual) + +# 6. Document source versions +SKELETON_SHA=$(git rev-parse HEAD) +cat > HORIZON_VERSION.md << EOF +# Core Theme Source Documentation + +Created: $(date) + +## Base (Skeleton) +- Repo: https://github.com/Shopify/skeleton-theme +- Commit: $SKELETON_SHA + +## Features (Horizon) +- Repo: https://github.com/Shopify/horizon +- Commit: $HORIZON_SHA + +## Copied Components +- All 94 blocks (blocks/) +- All 41 sections (sections/) +- Assets, snippets, templates, config, locales + +## Notes +v1.0.0 is vanilla copy (zero modifications). +Future versions will add agency customizations. +EOF + +# 7. Commit Horizon copy +git add . +git commit -m "feat: copy Horizon features into Skeleton base + +Copied from Shopify Horizon commit $HORIZON_SHA +All 94 blocks, 41 sections, assets, snippets. +Zero modifications - vanilla copy for v1.0.0." + +# 8. Push to GitHub +git push origin main +``` + +**Validate**: +- [ ] `ls blocks/` shows ~94 files +- [ ] `ls sections/` shows ~41 files +- [ ] `git log` shows Skeleton commits + your Horizon copy commit +- [ ] HORIZON_VERSION.md exists with correct commit SHAs + +**If fails**: Verify GitHub fork exists, remotes correct, Horizon clone succeeded + +--- + +### Day 2: NPM Package Configuration + +**Goal**: Configure package.json for npm publishing + +#### 1. Create package.json + +**Requirements**: +- Package name: `@agency/shopify-core` +- Version: `1.0.0` +- Registry: GitHub Packages (`https://npm.pkg.github.com`) +- Include all theme directories in published package +- Exclude: `.DS_Store`, `node_modules`, `.git`, `.github` + +**Example structure** (adapt as needed): +```json +{ + "name": "@agency/shopify-core", + "version": "1.0.0", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "files": [ + "assets/**", "blocks/**", "sections/**", "snippets/**", + "templates/**", "config/**", "layout/**", "locales/**", + "HORIZON_VERSION.md", "PRUNING_STRATEGY.md" + ] +} +``` + +#### 2. Create Documentation Files + +**README.md** must include: +- Package name and purpose +- Installation instructions (`npm install` + `npm run install-core`) +- Update instructions (`npm update` + `npm run update-core`) +- Architecture summary (Skeleton + Horizon) +- Link to detailed docs + +**CHANGELOG.md** must include: +- v1.0.0 release notes +- What's included (Skeleton + Horizon vanilla copy) +- Source commit SHAs +- Note about zero modifications + +**PRUNING_STRATEGY.md** must include: +- Goal: Remove unused blocks after 6-12 months +- Annual review process +- How to track usage +- Archive strategy (preserve code in `archived-blocks/`) + +#### 3. Create the Files + +Create package.json, README.md, CHANGELOG.md, and PRUNING_STRATEGY.md based on the requirements above. + +```bash +cd shopify-agency-core/ + +# Create each file with content meeting the requirements above +# Adapt examples as needed for your specific context +``` + +#### 4. Test Package Structure + +Verify the npm package is correctly configured before publishing. + +```bash +# Test npm pack +npm pack +# Creates: agency-shopify-core-1.0.0.tgz + +# Test install in temp directory +mkdir -p /tmp/test-npm-theme +cd /tmp/test-npm-theme +npm init -y +npm install ~/shopify-agency-core/agency-shopify-core-1.0.0.tgz + +# Verify structure +ls -la node_modules/@agency/shopify-core/ +# Should show: assets/, blocks/, sections/, snippets/, templates/, config/, layout/, locales/ + +# Validate theme with Shopify CLI +cd node_modules/@agency/shopify-core/ +shopify theme check +# Expected: "✓ Theme is valid" +``` + +#### 5. Commit Configuration + +**Validate**: +- [ ] package.json has correct "files" whitelist +- [ ] `npm pack` creates .tgz without errors +- [ ] Test install shows correct directory structure +- [ ] `shopify theme check` passes +- [ ] No .git/, .github/, or other excluded files in tarball + +**If fails**: +- Check "files" array in package.json +- Verify theme structure matches Shopify requirements +- Run `tar -tzf agency-shopify-core-1.0.0.tgz | less` to inspect contents + +**Commit**: +```bash +cd ~/shopify-agency-core/ +git add package.json README.md CHANGELOG.md PRUNING_STRATEGY.md +git commit -m "chore: add npm package configuration" +git push origin main +``` + +--- + +### Day 3: GitHub Actions & First Publish + +**Goal**: Automate publishing, test with v1.0.0 + +```bash +cd shopify-agency-core/ + +# 1. Create GitHub Actions workflow +mkdir -p .github/workflows +cat > .github/workflows/publish-to-npm.yml << 'EOF' +name: Publish to GitHub Packages + +on: + push: + tags: + - 'v*' # Triggers on v1.0.0, v1.1.0, etc. + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + registry-url: 'https://npm.pkg.github.com' + scope: '@agency' + + - name: Publish to GitHub Packages + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +EOF + +# 2. Commit workflow +git add .github/workflows/publish-to-npm.yml +git commit -m "ci: add automated npm publish workflow" +git push origin main + +# 3. Grant GitHub Actions workflow permissions +# Go to: GitHub repo → Settings → Actions → General +# Workflow permissions: "Read and write permissions" → Save + +# 4. Test with test tag (dry run) +git tag v1.0.0-test +git push origin v1.0.0-test + +# 5. Check GitHub Actions +# Go to: GitHub repo → Actions tab +# Should see "Publish to GitHub Packages" workflow running +# Wait for completion (green checkmark) + +# 6. Verify test publish +npm view @agency/shopify-core@1.0.0-test +# Should show package metadata + +# 7. Cleanup test +git tag -d v1.0.0-test +git push origin :refs/tags/v1.0.0-test +npm unpublish @agency/shopify-core@1.0.0-test --force + +# 8. Publish v1.0.0 FOR REAL +git tag v1.0.0 +git push origin v1.0.0 + +# 9. Wait for GitHub Actions (check Actions tab) + +# 10. Verify published +npm view @agency/shopify-core@1.0.0 +npm info @agency/shopify-core +``` + +**Validate**: +- [ ] GitHub Actions workflow exists in repo +- [ ] Workflow permissions set to "Read and write" +- [ ] Test tag triggers workflow successfully +- [ ] `npm view` shows package published +- [ ] v1.0.0 published and accessible + +**If fails**: +- Check GitHub Actions logs for errors +- Verify PAT has `write:packages` scope +- Verify .npmrc configured correctly +- Check workflow permissions in repo settings + +--- + +### Day 4: Client Repo Template & Install Script + +**Goal**: Create client repo template, test install workflow + +#### 1. Create Test Client Repo + +```bash +# Create test client repo on GitHub +# Go to: GitHub org → New repository +# Name: "test-client-theme" +# Private, Initialize with README + +# Clone client repo +git clone https://github.com/agency/test-client-theme.git +cd test-client-theme/ +``` + +#### 2. Create package.json + +```bash +# Create package.json (example structure - adapt as needed) +# Key requirements: +# - Dependency: @agency/shopify-core with ^ range (e.g., ^1.0.0) +# - Scripts: install-core and update-core pointing to scripts/ +# - Private: true (client repos are private) + +cat > package.json << 'EOF' +{ + "name": "test-client-theme", + "version": "1.0.0", + "private": true, + "dependencies": { + "@agency/shopify-core": "^1.0.0" + }, + "scripts": { + "install-core": "node scripts/install-core.js", + "update-core": "node scripts/update-core.js" + } +} +EOF + +# Install core theme via npm +npm install +# Installs @agency/shopify-core to node_modules/ +``` + +#### 3. Create Install Script (scripts/install-core.js) + +**Requirements**: +- Check if `@agency/shopify-core` exists in node_modules (error if not) +- Read core version from core package.json +- Copy these directories from core to root: `assets`, `blocks`, `sections`, `snippets`, `templates`, `config`, `layout`, `locales` +- Don't overwrite existing client files (preserve client customizations) +- Save core version to `.core-version` file at root +- Show progress (which directories copied) +- Handle errors gracefully (exit with error code if fails) + +**Pseudocode**: +```javascript +// Use fs-extra for recursive copy with options +// Path: node_modules/@agency/shopify-core → repo root +// For each directory: copy if exists, skip if already exists (overwrite: false) +// Read version from core's package.json +// Write version to .core-version +// Log success with version number +``` + +**Key considerations**: +- Initial install only (doesn't overwrite existing files) +- Must work when run from package.json script +- Should be idempotent (safe to run multiple times) + +#### 4. Create Update Script (scripts/update-core.js) + +**Requirements**: +- Read current version from `.core-version` +- Read new version from core package.json +- Copy ALL core directories to root (overwrite: true) +- Update `.core-version` with new version +- Show before/after versions +- Warn user to review changes with `git status` +- Remind about merge conflicts (expected for modified files) + +**Pseudocode**: +```javascript +// Read current version from .core-version +// Read new version from core package.json +// For each directory: copy and OVERWRITE (overwrite: true) +// Update .core-version file +// Log update complete + warnings about reviewing git status +``` + +**Key considerations**: +- Overwrites ALL core files (Git will show changes) +- Client customizations preserved via Git merge (not script logic) +- Should warn about potential merge conflicts + +#### 5. Complete Client Setup + +```bash +# Install fs-extra dependency +npm install --save-dev fs-extra + +# Create the scripts based on requirements above +mkdir -p scripts +# Write install-core.js and update-core.js + +# Run install script +npm run install-core + +# Verify files copied to root +ls -la +# Should see: assets/, blocks/, sections/, snippets/, templates/, config/, layout/, locales/, .core-version + +# Check .core-version +cat .core-version +# Should show: 1.0.0 + +# Validate theme +shopify theme check +# Should pass: Theme is valid + +# Create .gitignore +cat > .gitignore << 'EOF' +node_modules/ +.shopifyignore +.DS_Store +*.log +.env +EOF + +# Commit initial setup +git add . +git commit -m "chore: initial client repo setup with core v1.0.0" +git push origin main +``` + +**Validate**: +- [ ] Core files copied to root successfully +- [ ] `.core-version` file shows "1.0.0" +- [ ] `shopify theme check` passes +- [ ] All theme directories at root (not in subdirectories) +- [ ] Scripts run without errors + +**If fails**: +- Check npm install succeeded +- Verify @agency/shopify-core exists in node_modules +- Check script syntax (Node.js errors) +- Verify file paths correct + +--- + +### Day 4 (continued): Shopify GitHub Integration Test + +**Goal**: Verify GitHub → Shopify auto-sync works + +```bash +cd test-client-theme/ + +# 1. Connect repo to Shopify store +# In Shopify Admin: +# - Online Store → Themes +# - Add theme → Connect from GitHub +# - Select: agency/test-client-theme, branch: main +# - Theme Name: "Test Core Integration" + +# 2. Wait for Shopify to sync (1-2 minutes) +# Shopify will pull main branch and deploy as unpublished theme + +# 3. Verify theme appears in Shopify +# Shopify Admin → Themes +# Should see: "Test Core Integration" (unpublished) + +# 4. Test GitHub → Shopify sync +# Make a small change locally +echo "/* Test sync */" >> assets/base.css +git add assets/base.css +git commit -m "test: verify GitHub → Shopify sync" +git push origin main + +# Wait 30-60 seconds + +# 5. Verify change in Shopify +# Shopify Admin → Themes → Test Core Integration → Edit code +# Open assets/base.css +# Should see "/* Test sync */" at bottom + +# 6. Test Shopify → GitHub sync +# In Shopify theme editor: +# - Make a change (e.g., edit a section) +# - Save +# - Wait 1-2 minutes + +# Check Git commits +git pull origin main +git log --oneline -5 +# Should see commit from "Shopify GitHub Bot" + +# 7. Cleanup test commit +git revert HEAD~1 # Revert test sync commit +git push origin main +``` + +**Validate**: +- [ ] Shopify connected to GitHub repo +- [ ] Theme appears in Shopify admin +- [ ] Local commits appear in Shopify (GitHub → Shopify) +- [ ] Theme editor changes commit to Git (Shopify → GitHub) +- [ ] Two-way sync working + +**If fails**: +- Verify repo is public OR Shopify has GitHub App access +- Check Shopify admin for sync errors +- Verify branch name is "main" (not "master") +- Re-connect GitHub integration in Shopify admin + +--- + +### Day 5: Documentation & Finalization + +**Goal**: Finalize documentation, prepare for team + +#### 1. Create CORE.md (Development Guidelines) + +**Must include**: +- What belongs in core (universal features, Shopify best practices, reusable blocks) +- What stays in client repos (client-specific features, brand styling, third-party integrations) +- Contribution workflow (feature branch → develop → validate → PR → merge → publish) +- Versioning rules (patch/minor/major) +- Publishing process (`npm version` + `git push --tags` triggers GitHub Actions) + +#### 2. Update README.md (Main Documentation) + +**Must include**: +- Package name and description +- Architecture summary (Skeleton + Horizon, npm package, install script pattern) +- For clients: + - Installation instructions (`npm install` + `npm run install-core`) + - Update instructions (`npm update` + `npm run update-core`) +- For core developers: + - Link to CORE.md + - Publishing workflow (`npm version` + push tags) +- Documentation index (links to CORE.md, CHANGELOG.md, etc.) +- Support channels (Slack, JIRA) + +#### 3. Create CONTRIBUTING.md (Contributor Guide) + +**Must include**: +- Step-by-step development workflow +- Local development setup (`shopify theme dev`) +- Validation steps (`shopify theme check`) +- Commit message format (conventional commits) +- PR process +- Publishing after merge + +#### 4. Finalize and Publish + +```bash +# Commit all documentation +git add CORE.md README.md CONTRIBUTING.md +git commit -m "docs: add development and contribution guidelines" +git push origin main + +# Create GitHub release for v1.0.0 +# Go to: GitHub repo → Releases → Create new release +# Tag: v1.0.0 +# Title: "v1.0.0 - Initial Release" +# Description: Summarize what's included (Skeleton + Horizon, zero mods) +``` + +**Validate**: +- [ ] All documentation files created +- [ ] README clear and covers both client and developer workflows +- [ ] CORE.md defines boundaries (core vs client) +- [ ] CONTRIBUTING.md provides step-by-step instructions +- [ ] GitHub release created for v1.0.0 + +--- + +## Post-Implementation Verification + +### Test Complete Workflow + +```bash +# 1. Install core in new client repo +mkdir test-client-2 && cd test-client-2 +git init +npm init -y +npm install @agency/shopify-core@^1.0.0 +# Create scripts/ (copy from test-client-theme) +npm run install-core + +# 2. Verify installation +ls -la # Should show theme directories at root +cat .core-version # Should show "1.0.0" +shopify theme check # Should pass + +# 3. Make client customization +echo ".custom { color: red; }" > assets/custom.css +git add . && git commit -m "chore: initial setup + custom CSS" + +# 4. Simulate core update +# (In core repo: make change, bump to v1.1.0, publish) +npm update @agency/shopify-core # Updates to 1.1.0 +npm run update-core # Copies new version +git status # Shows core files changed, custom.css unchanged +git add . && git commit -m "chore: update core to v1.1.0" + +# 5. Verify both client and core files present +ls assets/ +# Should show: core files + custom.css +``` + +### Success Criteria + +- [ ] Core publishes to GitHub Packages +- [ ] Clients install core via npm +- [ ] Install script copies files to root +- [ ] Theme passes Shopify validation +- [ ] Shopify GitHub integration syncs both ways +- [ ] Core updates merge cleanly with client changes +- [ ] Documentation is clear and actionable + +--- + +## Common Issues & Solutions + +### npm install fails +```bash +# Check authentication +npm whoami --registry=https://npm.pkg.github.com + +# Verify .npmrc +cat ~/.npmrc +# Should contain: +# @agency:registry=https://npm.pkg.github.com +# //npm.pkg.github.com/:_authToken=ghp_YOUR_PAT +``` + +### Theme check fails +```bash +# Common issue: Files in wrong location +# Fix: Ensure theme files at root (not in subdirectories) + +# Validate structure +shopify theme check --verbose +# Read errors, fix file structure +``` + +### Shopify GitHub sync not working +```bash +# Check GitHub App connection in Shopify admin +# Settings → Apps and sales channels → GitHub → Reconnect + +# Verify repo visibility +# Private repos require GitHub App access +# Public repos work without extra config +``` + +### Install script fails +```bash +# Check if core is installed +ls node_modules/@agency/shopify-core/ +# If missing: npm install + +# Check script syntax +node scripts/install-core.js +# Read error messages, fix Node.js syntax issues +``` + +### Merge conflicts on core update +```bash +# Expected when client modified core files +# Strategy: Keep BOTH changes + +# Example: assets/product-card.css conflict +git status # Shows conflict +git diff # Shows both versions + +# Edit file: keep client changes + core updates +# Remove conflict markers: <<<<, ====, >>>> + +git add assets/product-card.css +git commit -m "merge: resolve core update conflicts" +``` + +--- + +## Next Steps + +After Day 5 completion: + +1. **Hanro Migration**: Begin migrating Hanro theme to use core + - See separate Hanro migration plan + - Use as pilot to validate core system + +2. **Extract Learnings**: After Hanro migration (Week 6+) + - Identify agency-specific features worth adding to core + - Publish v1.1.0+ with proven enhancements + +3. **Team Training**: Schedule workshop + - Core development workflow + - Client setup workflow + - Update procedures + - Troubleshooting + +4. **Rollout to Other Clients**: Gradual migration + - Start with low-complexity clients + - Learn from each migration + - Refine process + +--- + +## Summary: 5-Day Timeline + +| Day | Goal | Deliverable | +|-----|------|-------------| +| 1 | Initialize repo | Core repo with Skeleton + Horizon | +| 2 | NPM config | package.json, test pack/install | +| 3 | CI/CD | GitHub Actions, v1.0.0 published | +| 4 | Client template | Test client repo, scripts working | +| 5 | Documentation | All docs finalized, team ready | + +**Total Effort**: ~5 days for 1 developer, or 2-3 days for 2 developers in parallel + +--- + +**End of Execution Plan** diff --git a/HORIZON_VERSION.md b/HORIZON_VERSION.md new file mode 100644 index 000000000..ae1a6c99f --- /dev/null +++ b/HORIZON_VERSION.md @@ -0,0 +1,25 @@ +# Core Theme Source Documentation + +Created: 2026-02-17 + +## Base (Skeleton) +- Repo: https://github.com/Shopify/skeleton-theme +- Commit: 04069e0feda9f7f8bda8df65ca5a22791c61c997 + +## Features (Horizon) +- Repo: https://github.com/Shopify/horizon +- Commit: a6a3484ce86dea3810290bad9c475847ba504c86 + +## Copied Components +- 94 blocks (blocks/) +- 52 sections (sections/ -- includes Skeleton originals + Horizon additions) +- 116 assets (assets/) +- 95 snippets (snippets/) +- 13 templates (templates/) +- 51 locale files (locales/) +- Config: settings_schema.json and settings_data.json (Horizon replacements) +- Layout: theme.liquid (Horizon replacement) + +## Notes +v1.0.0 is vanilla copy (zero modifications). +Future versions will add agency customizations. diff --git a/LICENSE-HORIZON.md b/LICENSE-HORIZON.md new file mode 100644 index 000000000..5d33adf2c --- /dev/null +++ b/LICENSE-HORIZON.md @@ -0,0 +1,13 @@ +Copyright (c) 2025-present Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, sell and/or create derivative works of the Software or any part thereof, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The rights granted above may only be exercised to develop themes that integrate or interoperate with Shopify software or services. All other uses of the Software are strictly prohibited. + +For the avoidance of doubt, you may not submit, list, market, sell, distribute, or otherwise make available any theme that is based on, derived from, or incorporates any portion of the Software (a “Derived Theme”) via the Shopify Theme Store, any other Shopify-operated channel, or any off-platform channel (including your own website or third-party marketplaces). Shopify may determine, in its sole discretion, whether a theme is a Derived Theme for purposes of this restriction. You may not circumvent this restriction by making insubstantial changes, reformatting or reorganizing code, or by otherwise claiming independent creation. + +Notwithstanding the foregoing, you may deliver a Derived Theme directly to merchants as part of services engagements, solely for those merchants’ own use in their Shopify stores. Any such delivery may not be marketed or offered as a general-purpose product, and the Derived Theme may not be resold, relicensed, or redistributed by you or the merchant. + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.md b/LICENSE-SKELETON.md similarity index 100% rename from LICENSE.md rename to LICENSE-SKELETON.md diff --git a/assets/accordion-custom.js b/assets/accordion-custom.js new file mode 100644 index 000000000..b5112457e --- /dev/null +++ b/assets/accordion-custom.js @@ -0,0 +1,107 @@ +import { mediaQueryLarge, isMobileBreakpoint } from '@theme/utilities'; + +// Accordion +// Still extends HTMLElement over Component so that refs are still available to parent components (e.g. SortingFilterComponent) +class AccordionCustom extends HTMLElement { + /** @type {HTMLDetailsElement} */ + get details() { + const details = this.querySelector('details'); + + if (!(details instanceof HTMLDetailsElement)) throw new Error('Details element not found'); + + return details; + } + + /** @type {HTMLElement} */ + get summary() { + const summary = this.details.querySelector('summary'); + + if (!(summary instanceof HTMLElement)) throw new Error('Summary element not found'); + + return summary; + } + + get #disableOnMobile() { + return this.dataset.disableOnMobile === 'true'; + } + + get #disableOnDesktop() { + return this.dataset.disableOnDesktop === 'true'; + } + + get #closeWithEscape() { + return this.dataset.closeWithEscape === 'true'; + } + + #controller = new AbortController(); + + connectedCallback() { + const { signal } = this.#controller; + + this.#setDefaultOpenState(); + + this.addEventListener('keydown', this.#handleKeyDown, { signal }); + this.summary.addEventListener('click', this.handleClick, { signal }); + mediaQueryLarge.addEventListener('change', this.#handleMediaQueryChange, { signal }); + } + + /** + * Handles the disconnect event. + */ + disconnectedCallback() { + // Disconnect all the event listeners + this.#controller.abort(); + } + + /** + * Handles the click event. + * @param {Event} event - The event. + */ + handleClick = (event) => { + const isMobile = isMobileBreakpoint(); + const isDesktop = !isMobile; + + // Stop default behaviour from the browser + if ((isMobile && this.#disableOnMobile) || (isDesktop && this.#disableOnDesktop)) { + event.preventDefault(); + return; + } + }; + + /** + * Handles the media query change event. + */ + #handleMediaQueryChange = () => { + this.#setDefaultOpenState(); + }; + + /** + * Sets the default open state of the accordion based on the `open-by-default-on-mobile` and `open-by-default-on-desktop` attributes. + */ + #setDefaultOpenState() { + const isMobile = isMobileBreakpoint(); + + this.details.open = + (isMobile && this.hasAttribute('open-by-default-on-mobile')) || + (!isMobile && this.hasAttribute('open-by-default-on-desktop')); + } + + /** + * Handles keydown events for the accordion + * + * @param {KeyboardEvent} event - The keyboard event. + */ + #handleKeyDown(event) { + // Close the accordion when used as a menu + if (event.key === 'Escape' && this.#closeWithEscape) { + event.preventDefault(); + + this.details.open = false; + this.summary.focus(); + } + } +} + +if (!customElements.get('accordion-custom')) { + customElements.define('accordion-custom', AccordionCustom); +} diff --git a/assets/account-login-actions.js b/assets/account-login-actions.js new file mode 100644 index 000000000..4e6dbef48 --- /dev/null +++ b/assets/account-login-actions.js @@ -0,0 +1,37 @@ +import { Component } from '@theme/component'; + +/** + * A custom element that manages the account login actions. + * + * @extends {Component} + */ +class AccountLoginActions extends Component { + /** + * @type {Element | null} + */ + shopLoginButton = null; + + connectedCallback() { + super.connectedCallback(); + this.shopLoginButton = this.querySelector('shop-login-button'); + + if (this.shopLoginButton) { + // We don't have control over the shop-login-button markup, so we need to set additional attributes here + this.shopLoginButton.setAttribute('full-width', 'true'); + this.shopLoginButton.setAttribute('persist-after-sign-in', 'true'); + // Do this only if New Customer Account is ALWAYS the sign in option (and never Classic Customer Account) + this.shopLoginButton.setAttribute('analytics-context', 'loginWithShopSelfServe'); + this.shopLoginButton.setAttribute('flow-version', 'account-actions-popover'); + this.shopLoginButton.setAttribute('return-uri', window.location.href); + + // Reload the page after the login is completed, otherwise the page state is incorrect + this.shopLoginButton.addEventListener('completed', () => { + window.location.reload(); + }); + } + } +} + +if (!customElements.get('account-login-actions')) { + customElements.define('account-login-actions', AccountLoginActions); +} diff --git a/assets/anchored-popover.js b/assets/anchored-popover.js new file mode 100644 index 000000000..0f5709618 --- /dev/null +++ b/assets/anchored-popover.js @@ -0,0 +1,132 @@ +import { Component } from '@theme/component'; +import { debounce, requestIdleCallback } from '@theme/utilities'; + +/** + * A custom element that manages the popover + popover trigger relationship for anchoring. + * Calculates the trigger position and inlines custom properties on the popover element + * that can be consumed by CSS for positioning. + * + * @typedef {object} Refs + * @property {HTMLElement} popover – The popover element. + * @property {HTMLElement} trigger – The popover trigger element. + * + * @extends Component + * + * @example + * ```html + * + * + * + * + * ``` + * + * @property {string[]} requiredRefs - Required refs: 'popover' and 'trigger' + * @property {number} [interaction_delay] - The delay in milliseconds for the hover interaction + * @property {string} [data-close-on-resize] - When present, closes popover on window resize + * @property {string} [data-hover-triggered] - When present, makes the popover function via pointerenter/leave + * @property {number | null} [popoverTrigger] - The timeout for the popover trigger + */ +export class AnchoredPopoverComponent extends Component { + requiredRefs = ['popover', 'trigger']; + interaction_delay = 200; + #popoverTrigger = /** @type {number | null} */ (null); + + #onTriggerEnter = () => { + const { trigger, popover } = this.refs; + trigger.dataset.hoverActive = 'true'; + if (!popover.matches(':popover-open')) { + this.#popoverTrigger = setTimeout(() => { + if (trigger.matches('[data-hover-active]')) popover.showPopover(); + }, this.interaction_delay); + } + }; + + #onTriggerLeave = () => { + const { trigger, popover } = this.refs; + delete trigger.dataset.hoverActive; + if (this.#popoverTrigger) clearTimeout(this.#popoverTrigger); + if (popover.matches(':popover-open')) { + this.#popoverTrigger = setTimeout(() => { + popover.hidePopover(); + }, this.interaction_delay); + } + }; + + #onPopoverEnter = () => { + if (this.#popoverTrigger) clearTimeout(this.#popoverTrigger); + }; + + #onPopoverLeave = () => { + const { popover } = this.refs; + this.#popoverTrigger = setTimeout(() => { + popover.hidePopover(); + }, this.interaction_delay); + }; + + /** + * Updates the popover position by calculating trigger element bounds + * and setting CSS custom properties on the popover element. + */ + #updatePosition = async () => { + const { popover, trigger } = this.refs; + if (!popover || !trigger) return; + const positions = trigger.getBoundingClientRect(); + popover.style.setProperty('--anchor-top', `${positions.top}`); + popover.style.setProperty('--anchor-right', `${window.innerWidth - positions.right}`); + popover.style.setProperty('--anchor-bottom', `${window.innerHeight - positions.bottom}`); + popover.style.setProperty('--anchor-left', `${positions.left}`); + popover.style.setProperty('--anchor-height', `${positions.height}`); + popover.style.setProperty('--anchor-width', `${positions.width}`); + }; + + /** + * Debounced resize handler that optionally closes the popover + * when the window is resized, based on the data-close-on-resize attribute. + */ + #resizeListener = debounce(() => { + const popover = /** @type {HTMLElement} */ (this.refs.popover); + if (popover && popover.matches(':popover-open')) { + popover.hidePopover(); + } + }, 100); + + /** + * Component initialization - sets up event listeners for resize and popover toggle events. + */ + connectedCallback() { + super.connectedCallback(); + const { popover, trigger } = this.refs; + if (this.dataset.closeOnResize) { + popover.addEventListener('beforetoggle', (event) => { + const evt = /** @type {ToggleEvent} */ (event); + window[evt.newState === 'open' ? 'addEventListener' : 'removeEventListener']('resize', this.#resizeListener); + }); + } + if (this.dataset.hoverTriggered) { + trigger.addEventListener('pointerenter', this.#onTriggerEnter); + trigger.addEventListener('pointerleave', this.#onTriggerLeave); + popover.addEventListener('pointerenter', this.#onPopoverEnter); + popover.addEventListener('pointerleave', this.#onPopoverLeave); + } + if (!CSS.supports('position-anchor: --trigger')) { + popover.addEventListener('beforetoggle', () => { + this.#updatePosition(); + }); + requestIdleCallback(() => { + this.#updatePosition(); + }); + } + } + + /** + * Component cleanup - removes resize event listener. + */ + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('resize', this.#resizeListener); + } +} + +if (!customElements.get('anchored-popover-component')) { + customElements.define('anchored-popover-component', AnchoredPopoverComponent); +} diff --git a/assets/announcement-bar.js b/assets/announcement-bar.js new file mode 100644 index 000000000..37de90aa2 --- /dev/null +++ b/assets/announcement-bar.js @@ -0,0 +1,130 @@ +import { Component } from '@theme/component'; + +/** + * Announcement banner custom element that allows fading between content. + * Based on the Slideshow component. + * + * @typedef {object} Refs + * @property {HTMLElement} slideshowContainer + * @property {HTMLElement[]} [slides] + * @property {HTMLButtonElement} [previous] + * @property {HTMLButtonElement} [next] + * + * @extends {Component} + */ +export class AnnouncementBar extends Component { + #current = 0; + + /** + * The interval ID for automatic playback. + * @type {number|undefined} + */ + #interval = undefined; + + connectedCallback() { + super.connectedCallback(); + + this.addEventListener('mouseenter', this.suspend); + this.addEventListener('mouseleave', this.resume); + document.addEventListener('visibilitychange', this.#handleVisibilityChange); + + this.play(); + } + + next() { + this.current += 1; + } + + previous() { + this.current -= 1; + } + + /** + * Starts automatic slide playback. + * @param {number} [interval] - The time interval in seconds between slides. + */ + play(interval = this.autoplayInterval) { + if (!this.autoplay) return; + + this.paused = false; + + this.#interval = setInterval(() => { + if (this.matches(':hover') || document.hidden) return; + + this.next(); + }, interval); + } + + /** + * Pauses automatic slide playback. + */ + pause() { + this.paused = true; + this.suspend(); + } + + get paused() { + return this.hasAttribute('paused'); + } + + set paused(paused) { + this.toggleAttribute('paused', paused); + } + + /** + * Suspends automatic slide playback. + */ + suspend() { + clearInterval(this.#interval); + this.#interval = undefined; + } + + /** + * Resumes automatic slide playback if autoplay is enabled. + */ + resume() { + if (!this.autoplay || this.paused) return; + + this.pause(); + this.play(); + } + + get autoplay() { + return Boolean(this.autoplayInterval); + } + + get autoplayInterval() { + const interval = this.getAttribute('autoplay'); + const value = parseInt(`${interval}`, 10); + + if (Number.isNaN(value)) return undefined; + + return value * 1000; + } + + get current() { + return this.#current; + } + + set current(current) { + this.#current = current; + + let relativeIndex = current % (this.refs.slides ?? []).length; + if (relativeIndex < 0) { + relativeIndex += (this.refs.slides ?? []).length; + } + + this.refs.slides?.forEach((slide, index) => { + slide.setAttribute('aria-hidden', `${index !== relativeIndex}`); + }); + } + + /** + * Pause the slideshow when the page is hidden. + */ + #handleVisibilityChange = () => (document.hidden ? this.pause() : this.resume()); +} + +if (!customElements.get('announcement-bar-component')) { + customElements.define('announcement-bar-component', AnnouncementBar); +} diff --git a/assets/auto-close-details.js b/assets/auto-close-details.js new file mode 100644 index 000000000..6af50e14a --- /dev/null +++ b/assets/auto-close-details.js @@ -0,0 +1,15 @@ +(function autoCloseDetails() { + document.addEventListener('click', function (event) { + const detailsToClose = [...document.querySelectorAll('details[data-auto-close-details][open]')].filter( + (element) => { + const closingOn = window.innerWidth < 750 ? 'mobile' : 'desktop'; + return ( + element.getAttribute('data-auto-close-details')?.includes(closingOn) && + !(event.target instanceof Node && element.contains(event.target)) + ); + } + ); + + for (const detailsElement of detailsToClose) detailsElement.removeAttribute('open'); + }); +})(); diff --git a/assets/base.css b/assets/base.css new file mode 100644 index 000000000..664cbf3dd --- /dev/null +++ b/assets/base.css @@ -0,0 +1,4946 @@ +* { + box-sizing: border-box; +} + +body { + color: var(--color-foreground); + background: var(--color-background); + display: flex; + flex-direction: column; + margin: 0; + min-height: 100svh; + font-variation-settings: 'slnt' 0; +} + +:root { + --hover-lift-amount: 4px; + --hover-scale-amount: 1.03; + --hover-subtle-zoom-amount: 1.015; + --hover-shadow-color: var(--color-shadow); + --hover-transition-duration: 0.25s; + --hover-transition-timing: ease-out; + --surface-transition-duration: 0.3s; + --surface-transition-timing: var(--ease-out-quad); + --submenu-animation-speed: 360ms; + --submenu-animation-easing: cubic-bezier(0.25, 0.1, 0.25, 1); +} + +html { + /* Firefox */ + scrollbar-width: thin; + scrollbar-color: rgb(var(--color-foreground-rgb) / var(--opacity-40)) var(--color-background); + scroll-behavior: smooth; +} + +html[scroll-lock] { + overflow: hidden; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +img { + width: 100%; + height: auto; +} + +input, +textarea, +select { + font: inherit; + border-radius: var(--style-border-radius-inputs); +} + +input:hover, +textarea:hover { + background-color: var(--color-input-hover-background); +} + +/** override ios and firefox defaults */ +select { + background-color: var(--color-background); + color: currentcolor; +} + +.collection-card, +.featured-blog-posts-card { + width: 100%; + position: relative; + height: 100%; +} + +/* Editorial layout */ +.resource-list:not(.hidden--desktop) .collection-card--flexible-aspect-ratio, +.resource-list:not(.hidden--desktop) .blog-post-card--flexible-aspect-ratio { + .collection-card__image, + .featured-blog-posts-card__image, + .blog-placeholder-svg { + aspect-ratio: 99; + height: 100%; + } + + .collection-card__inner, + .featured-blog-posts-card__inner { + display: flex; + flex-direction: column; + height: 100%; + } + + .collection-card__content, + .featured-blog-posts-card__content { + flex-shrink: 0; + } + + &:not(.collection-card--image-bg) .collection-card__content, + .featured-blog-posts-card__content { + height: auto; + } +} + +.collection-card__inner, +.featured-blog-posts-card__inner { + width: 100%; + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + z-index: var(--layer-flat); + pointer-events: none; +} + +.collection-card__content, +.featured-blog-posts-card__content { + display: flex; + position: relative; + height: 100%; + width: 100%; + gap: var(--gap); +} + +.collection-card__link, +.featured-blog-posts-card__link { + position: absolute; + inset: 0; + + /* allows focus outline to have radius in supported browsers */ + border-radius: var(--border-radius); +} + +.product-card, +.collection-card, +.resource-card, +.predictive-search-results__card--product, +.predictive-search-results__card { + position: relative; + transition: transform var(--hover-transition-duration) var(--hover-transition-timing), + box-shadow var(--hover-transition-duration) var(--hover-transition-timing); + z-index: var(--layer-flat); +} + +.product-card__link { + position: absolute; + inset: 0; +} + +.product-card__content { + position: relative; +} + +.product-card__content { + cursor: pointer; +} + +.product-card__content slideshow-component { + --cursor: pointer; +} + +.predictive-search-results__card .product-card, +.predictive-search-results__card .collection-card, +.predictive-search-results__card .resource-card { + transition: none; + will-change: auto; +} + +@media (any-pointer: fine) and (prefers-reduced-motion: no-preference) { + .card-hover-effect-lift .product-card:hover, + .card-hover-effect-lift .collection-card:hover, + .card-hover-effect-lift .resource-card:hover, + .card-hover-effect-lift .predictive-search-results__card:hover { + transform: translateY(calc(-1 * var(--hover-lift-amount))); + } + + .card-hover-effect-lift .header .product-card:hover, + .card-hover-effect-lift .header .collection-card:hover, + .card-hover-effect-lift .header .resource-card:hover, + .card-hover-effect-lift .header-drawer .product-card:hover, + .card-hover-effect-lift .header-drawer .collection-card:hover, + .card-hover-effect-lift .header-drawer .resource-card:hover { + transform: none; + } + + .card-hover-effect-scale .product-card:hover, + .card-hover-effect-scale .collection-card:hover, + .card-hover-effect-scale .resource-card:hover, + .card-hover-effect-scale .predictive-search-results__card:hover { + transform: scale(var(--hover-scale-amount)); + } + + .card-hover-effect-scale .header .product-card:hover, + .card-hover-effect-scale .header .collection-card:hover, + .card-hover-effect-scale .header .resource-card:hover, + .card-hover-effect-scale .header-drawer .product-card:hover, + .card-hover-effect-scale .header-drawer .collection-card:hover, + .card-hover-effect-scale .header-drawer .resource-card:hover { + transform: none; + } + + .card-hover-effect-subtle-zoom .card-gallery, + .card-hover-effect-subtle-zoom .collection-card__image, + .card-hover-effect-subtle-zoom .product-card__image, + .card-hover-effect-subtle-zoom .resource-card__image { + overflow: hidden; + transition: transform var(--hover-transition-duration) var(--hover-transition-timing); + } + + .predictive-search-results__card .card-gallery, + .predictive-search-results__card .collection-card__image, + .predictive-search-results__card .product-card__image, + .predictive-search-results__card .resource-card__image { + transition: none; + } + + .card-hover-effect-subtle-zoom .product-card:hover .card-gallery, + .card-hover-effect-subtle-zoom .collection-card:hover .collection-card__image, + .card-hover-effect-subtle-zoom .product-card:hover .product-card__image, + .card-hover-effect-subtle-zoom .resource-card:hover .resource-card__image, + .card-hover-effect-subtle-zoom .predictive-search-results__card:hover { + transform: scale(var(--hover-subtle-zoom-amount)); + } + + .card-hover-effect-subtle-zoom .header .product-card:hover .card-gallery, + .card-hover-effect-subtle-zoom .header .collection-card:hover .collection-card__image, + .card-hover-effect-subtle-zoom .header .product-card:hover .product-card__image, + .card-hover-effect-subtle-zoom .header .resource-card:hover .resource-card__image, + .card-hover-effect-subtle-zoom .header-drawer .product-card:hover .card-gallery, + .card-hover-effect-subtle-zoom .header-drawer .collection-card:hover .collection-card__image, + .card-hover-effect-subtle-zoom .header-drawer .product-card:hover .product-card__image, + .card-hover-effect-subtle-zoom .header-drawer .resource-card:hover .resource-card__image { + transform: none; + } + + .predictive-search-results__card .product-card:hover, + .predictive-search-results__card .collection-card:hover, + .predictive-search-results__card .resource-card:hover, + .header .product-card:hover, + .header .collection-card:hover, + .header .resource-card:hover, + .header-drawer .product-card:hover, + .header-drawer .collection-card:hover, + .header-drawer .resource-card:hover { + transform: none; + box-shadow: none; + } +} + +dialog { + /* the ::backdrop inherits from the originating element, custom properties must be set on the dialog element */ + --backdrop-color-rgb: var(--color-shadow-rgb); + + background-color: var(--color-background); + color: var(--color-foreground); +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +.wrap-text { + overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; +} + +p:empty { + display: none; +} + +:first-child:is(p, h1, h2, h3, h4, h5, h6), +:first-child:empty + :where(p, h1, h2, h3, h4, h5, h6) { + margin-block-start: 0; +} + +/* Remove bottom margin from last text item, or previous to last if the last is empty */ +:last-child:is(p, h1, h2, h3, h4, h5, h6), +:where(p, h1, h2, h3, h4, h5, h6):nth-child(2):has(+ :last-child:empty) { + margin-block-end: 0; +} + +/* view transitions */ +@media (prefers-reduced-motion: no-preference) { + @view-transition { + navigation: auto; + } + + /* Keep page interactive while view transitions are running */ + :root { + view-transition-name: none; + } + + /* Have the root transition during page navigation */ + html:active-view-transition-type(page-navigation), + html:active-view-transition-type(product-image-transition) { + view-transition-name: root-custom; + } + + ::view-transition { + pointer-events: none; + } + + html:active-view-transition-type(page-navigation) main[data-page-transition-enabled='true'] { + view-transition-name: main-content; + } + + html:active-view-transition-type(page-navigation) main[data-product-transition='true'][data-template*='product'] { + view-transition-name: none; + } + + ::view-transition-old(main-content) { + animation: var(--view-transition-old-main-content); + } + + ::view-transition-new(main-content) { + animation: var(--view-transition-new-main-content); + } + + html:active-view-transition-type(product-image-transition) { + [data-view-transition-type='product-image-transition'] { + view-transition-name: product-image-transition; + } + + [data-view-transition-type='product-details'] { + view-transition-name: product-details; + } + } + + ::view-transition-group(product-image-transition) { + z-index: 1; + } + + ::view-transition-group(product-image-transition), + ::view-transition-group(product-details) { + animation-duration: var(--animation-speed); + animation-timing-function: var(--animation-easing); + } + + ::view-transition-old(product-image-transition), + ::view-transition-new(product-image-transition) { + block-size: 100%; + overflow: hidden; + object-fit: cover; + animation-duration: 0.25s; + animation-timing-function: var(--animation-easing); + } + + ::view-transition-new(product-details) { + animation: var(--view-transition-new-main-content); + } +} + +/* Focus */ +*:focus-visible { + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: var(--focus-outline-offset); +} + +@supports not selector(:focus-visible) { + *:focus { + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: var(--focus-outline-offset); + } +} + +.focus-inset { + outline-offset: calc(var(--focus-outline-width) * -1); +} + +/* Layout */ +.content-for-layout { + flex: 1; +} + +/* Set up page widths & margins */ +.page-width-wide, +.page-width-normal, +.page-width-narrow, +.page-width-content { + --page-margin: 16px; +} + +@media screen and (min-width: 750px) { + .page-width-wide, + .page-width-normal, + .page-width-narrow, + .page-width-content { + --page-margin: 40px; + } +} + +.page-width-wide { + /* NOTE: This results in a page width of 2400px because of how we set up margins with grid */ + --page-content-width: var(--wide-page-width); + --page-width: calc(var(--page-content-width) + (var(--page-margin) * 2)); +} + +.page-width-normal { + --page-content-width: var(--normal-page-width); + --page-width: calc(var(--page-content-width) + (var(--page-margin) * 2)); +} + +.page-width-narrow, +.page-width-content { + /* NOTE: This results in a page width of 1400px because of how we set up margins with grid */ + --page-content-width: var(--narrow-page-width); + --page-width: calc(var(--page-content-width) + (var(--page-margin) * 2)); +} + +.page-width-content { + --page-content-width: var(--normal-content-width); + --page-width: calc(var(--page-content-width) + (var(--page-margin) * 2)); +} + +/* Section width full vs. page + The reason we use a grid to contain the section is to allow for the section to have a + full-width background image even if the section content is constrained by the page width. Do not try + to rewrite this to max-width: --page-width; margin: 0 auto;, it doesn't work. */ +.section { + --full-page-grid-central-column-width: min( + var(--page-width) - var(--page-margin) * 2, + calc(100% - var(--page-margin) * 2) + ); + --full-page-grid-margin: minmax(var(--page-margin), 1fr); + --full-page-grid-with-margins: var(--full-page-grid-margin) var(--full-page-grid-central-column-width) + var(--full-page-grid-margin); + + /* Utility variable gives the grid's first column width. Provides an offset width for components like carousels */ + --util-page-margin-offset: max( + var(--page-margin), + calc((100% - min(var(--page-content-width), 100% - var(--page-margin) * 2)) / 2) + ); + + /* Offset for full-width sections to account for the page margin, + used for Marquee — note that --util-page-margin-offset doesn't work here */ + --full-page-margin-inline-offset: calc(((100vw - var(--full-page-grid-central-column-width)) / 2) * -1); + + width: 100%; + + /* This is required to make background images work, which are rendered absolutely */ + position: relative; + + /* Set up the grid */ + display: grid; + grid-template-columns: var(--full-page-grid-with-margins); + min-height: var(--section-min-height, 'auto'); +} + +/* Place all direct children in the center column by default */ +.section > * { + grid-column: 2; +} + +/* Make the actual section background transparent, and instead apply it to a separate sibling element to enable stacking with hero shadow */ +.shopify-section:not(.header-section) :is(.section, .cart-summary) { + background: transparent; +} + +.shopify-section:not(.header-section):has(.section) { + position: relative; +} + +.shopify-section:not(.header-section) .section-background { + content: ''; + position: absolute; + inset: 0; + z-index: var(--layer-section-background); +} + +/* For page-width sections, all content goes in the center column */ +.section--page-width > * { + grid-column: 2; +} + +/* For full-width sections, content spans all columns */ +.section--full-width > * { + grid-column: 1 / -1; +} + +@media screen and (max-width: 749px) { + .section--mobile-full-width > * { + grid-column: 1 / -1; + } +} + +/* Some page-width sections should still extend all the way to the right edge of the page, e.g. collection carousel */ +.section--page-width.section--full-width-right > * { + grid-column: 2 / 4; +} + +/* For full-width sections with margin, content still spans full width but with space on the sides */ +.section--full-width.section--full-width-margin > * { + grid-column: 1 / -1; + + @media screen and (min-width: 750px) { + padding-left: var(--page-margin); + padding-right: var(--page-margin); + } +} + +/* Some section content break out to full width of the page */ +.section > .force-full-width { + grid-column: 1 / -1; +} + +.section--height-small { + --section-min-height: var(--section-height-small); +} + +.section--height-medium { + --section-min-height: var(--section-height-medium); +} + +.section--height-large { + --section-min-height: var(--section-height-large); +} + +.section--height-full-screen { + --section-min-height: 100svh; +} + +.section-content-wrapper.section-content-wrapper { + min-height: calc(var(--section-min-height, 'auto') - var(--section-height-offset, 0px)); + position: relative; + width: 100%; + height: 100%; +} + +/* Utility */ + +.hidden { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; +} + +@media screen and (max-width: 749px) { + .hidden--mobile, + .mobile\:hidden { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +@media screen and (min-width: 750px) { + .hidden--desktop, + .desktop\:hidden { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; + } +} + +.hide-when-empty:empty { + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; +} + +.visually-hidden:not(:focus, :active) { + /* stylelint-disable-next-line declaration-no-important */ + position: absolute !important; + overflow: hidden; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + /* stylelint-disable-next-line declaration-no-important */ + word-wrap: normal !important; +} + +@media screen and (max-width: 749px) { + .is-visually-hidden-mobile:not(:focus, :active) { + /* stylelint-disable-next-line declaration-no-important */ + position: absolute !important; + overflow: hidden; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + /* stylelint-disable-next-line declaration-no-important */ + word-wrap: normal !important; + } +} + +.contents { + display: contents; +} + +.flex { + display: flex; + gap: var(--gap-md); +} + +.grid { + --centered-column-number: 12; + --full-width-column-number: 14; + --centered: column-1 / span var(--centered-column-number); + --full-width: column-0 / span var(--full-width-column-number); + + display: flex; + flex-direction: column; +} + +@media screen and (min-width: 750px) { + .grid { + display: grid; + gap: 0; + grid-template-columns: var(--margin-4xl) repeat(var(--centered-column-number), minmax(0, 1fr)) var(--margin-4xl); + grid-template-areas: 'column-0 column-1 column-2 column-3 column-4 column-5 column-6 column-7 column-8 column-9 column-10 column-11 column-12 column-13'; + } +} + +@media screen and (min-width: 1400px) { + .grid { + grid-template-columns: + 1fr repeat( + var(--centered-column-number), + minmax(0, calc((var(--page-width) - var(--page-margin) * 2) / var(--centered-column-number))) + ) + 1fr; + } +} + +.flex { + display: flex; + gap: var(--gap-md); +} + +.flip-x { + scale: -1 1; +} + +.flip-y { + scale: 1 -1; +} + +.list-unstyled { + margin: 0; + padding: 0; + list-style: none; +} + +.text-left { + --text-align: left; + + text-align: left; +} + +.text-center { + --text-align: center; + + text-align: center; +} + +.text-right { + --text-align: right; + + text-align: right; +} + +.text-inherit { + color: inherit; +} + +.user-select-text { + user-select: text; +} + +.justify-left { + justify-content: left; +} + +.justify-center { + justify-content: center; +} + +.justify-right { + justify-content: right; +} + +.title--aligned-center { + display: flex; + align-items: center; + gap: 1rem; +} + +.background-image-container { + overflow: hidden; + position: absolute; + inset: 0; + opacity: var(--image-opacity); +} + +.background-image-container img, +.background-image-container svg { + object-fit: cover; + width: 100%; + height: 100%; +} + +.background-image-fit img, +.background-image-fit svg { + object-fit: contain; +} + +.svg-wrapper { + color: currentcolor; + display: inline-flex; + justify-content: center; + align-items: center; + width: var(--icon-size-sm); + height: var(--icon-size-sm); + pointer-events: none; +} + +.svg-wrapper--smaller { + width: var(--icon-size-2xs); + height: var(--icon-size-2xs); +} + +.svg-wrapper--small { + width: var(--icon-size-xs); + height: var(--icon-size-xs); +} + +.svg-wrapper > svg { + width: var(--icon-size-sm); + height: var(--icon-size-sm); +} + +.relative { + position: relative; +} + +/* Icons */ +.icon-success, +.icon-error { + width: var(--icon-size-md); + height: var(--icon-size-md); + flex-shrink: 0; +} + +.icon-success { + color: var(--color-success); +} + +.icon-error { + fill: var(--color-error); +} + +.icon-default { + fill: currentColor; +} + +[data-placeholder='true'] * { + cursor: default; +} + +slideshow-component [data-placeholder='true'] * { + cursor: grab; +} + +/* Base text and heading styles */ +body, +.paragraph:not(.button), +.paragraph > *, +.text-block.paragraph :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-paragraph--family); + font-style: var(--font-paragraph--style); + font-weight: var(--font-paragraph--weight); + font-size: var(--font-paragraph--size); + line-height: var(--font-paragraph--line-height); + text-transform: var(--font-paragraph--case); + -webkit-font-smoothing: antialiased; + color: var(--color, var(--color-foreground)); +} + +/* Ensure inputs with type presets maintain minimum 16px on mobile to prevent iOS zoom */ +@media screen and (max-width: 1200px) { + input.paragraph.paragraph, + input.paragraph.paragraph:not([type]), + textarea.paragraph.paragraph, + select.paragraph.paragraph { + font-size: max(1rem, var(--font-paragraph--size)); + } +} + +.paragraph > small { + font-size: smaller; +} + +/* Typography presets */ + +h1, +.h1.h1, +.text-block.h1 > *, +.text-block.h1 :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-h1--family); + font-style: var(--font-h1--style); + font-weight: var(--font-h1--weight); + font-size: var(--font-h1--size); + line-height: var(--font-h1--line-height); + letter-spacing: var(--font-h1--letter-spacing); + text-transform: var(--font-h1--case); + color: var(--color, var(--font-h1-color)); +} + +@media screen and (max-width: 1200px) { + input.h1.h1, + textarea.h1.h1, + select.h1.h1 { + font-size: max(1rem, var(--font-h1--size)); + } +} + +h2, +.h2.h2, +.text-block.h2 > *, +.text-block.h2 :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-h2--family); + font-style: var(--font-h2--style); + font-weight: var(--font-h2--weight); + font-size: var(--font-h2--size); + line-height: var(--font-h2--line-height); + letter-spacing: var(--font-h2--letter-spacing); + text-transform: var(--font-h2--case); + color: var(--color, var(--font-h2-color)); +} + +@media screen and (max-width: 1200px) { + input.h2.h2, + textarea.h2.h2, + select.h2.h2 { + font-size: max(1rem, var(--font-h2--size)); + } +} + +h3, +.h3, +.h3.h3, +.text-block.h3 > *, +.text-block.h3 :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-h3--family); + font-style: var(--font-h3--style); + font-weight: var(--font-h3--weight); + font-size: var(--font-h3--size); + line-height: var(--font-h3--line-height); + letter-spacing: var(--font-h3--letter-spacing); + text-transform: var(--font-h3--case); + color: var(--color, var(--font-h3-color)); +} + +@media screen and (max-width: 1200px) { + input.h3, + textarea.h3, + select.h3 { + font-size: max(1rem, var(--font-h3--size)); + } +} + +h4, +.h4.h4, +.text-block.h4 > *, +.text-block.h4 :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-h4--family); + font-style: var(--font-h4--style); + font-weight: var(--font-h4--weight); + font-size: var(--font-h4--size); + line-height: var(--font-h4--line-height); + letter-spacing: var(--font-h4--letter-spacing); + text-transform: var(--font-h4--case); + color: var(--color, var(--font-h4-color)); +} + +@media screen and (max-width: 1200px) { + input.h4.h4, + textarea.h4.h4, + select.h4.h4 { + font-size: max(1rem, var(--font-h4--size)); + } +} + +h5, +.h5.h5, +.text-block.h5 > *, +.text-block.h5 :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-h5--family); + font-style: var(--font-h5--style); + font-weight: var(--font-h5--weight); + font-size: var(--font-h5--size); + line-height: var(--font-h5--line-height); + letter-spacing: var(--font-h5--letter-spacing); + text-transform: var(--font-h5--case); + color: var(--color, var(--font-h5-color)); +} + +@media screen and (max-width: 1200px) { + input.h5.h5, + textarea.h5.h5, + select.h5.h5 { + font-size: max(1rem, var(--font-h5--size)); + } +} + +h6, +.h6.h6, +.text-block.h6 > *, +.text-block.h6 :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-h6--family); + font-style: var(--font-h6--style); + font-weight: var(--font-h6--weight); + font-size: var(--font-h6--size); + line-height: var(--font-h6--line-height); + letter-spacing: var(--font-h6--letter-spacing); + text-transform: var(--font-h6--case); + color: var(--color, var(--font-h6-color)); +} + +@media screen and (max-width: 1200px) { + input.h6.h6, + textarea.h6.h6, + select.h6.h6 { + font-size: max(1rem, var(--font-h6--size)); + } +} + +:first-child:is(.h1, .h2, .h3, .h4, .h5, .h6) { + margin-block-start: 0; +} + +:last-child:is(.h1, .h2, .h3, .h4, .h5, .h6) { + margin-block-end: 0; +} + +/* Links */ +a { + --button-color: var(--color, var(--color-primary)); + + color: var(--button-color); + text-decoration-color: transparent; + text-decoration-thickness: 0.075em; + text-underline-offset: 0.125em; + transition: text-decoration-color var(--animation-speed) var(--animation-easing), + color var(--animation-speed) var(--animation-easing); +} + +:is(h1, h2, h3, h4, h5, h6, p) > a:hover { + --button-color: var(--color, var(--color-primary-hover)); +} + +/* Add underline to text using our paragraph styles only. */ +p:not(.h1, .h2, .h3, .h4, .h5, .h6) a:where(:not(.button, .button-secondary)), +.rte :is(p, ul, ol, table):not(.h1, .h2, .h3, .h4, .h5, .h6) a:where(:not(.button, .button-secondary)) { + text-decoration-color: currentcolor; + + &:hover { + text-decoration-color: transparent; + color: var(--color-primary-hover); + } +} + +.container-background-image { + background-repeat: no-repeat; + background-size: cover; + background-position: center center; +} + +details[open] .summary-closed { + display: none; +} + +details:not([open]) .summary-open { + display: none; +} + +details[open] > summary .icon-animated > svg { + transform: rotate(180deg); +} + +/* iOS fix: hide the default arrow on the summary */ +summary::-webkit-details-marker { + display: none; +} + +/* When header is transparent, pull the first main content section up to sit under the floating header */ +body:has(.header[transparent]) .content-for-layout > .shopify-section:first-child { + margin-top: calc(var(--header-group-height) * -1); +} + +body:has(.header[transparent]) #header-group > .header-section { + z-index: var(--layer-sticky); +} + +/* All other header group content should be beneath the floating header, +but above the rest of the page content */ +body:has(.header[transparent]) #header-group > *:not(.header-section) { + z-index: calc(var(--layer-sticky) - 1); +} + +/* Featured collection block */ +.featured-collection-block { + width: 100%; +} + +/* Product grid */ +.product-grid-container { + display: block; + width: 100%; + padding-block: var(--padding-block-start) var(--padding-block-end); + + @media screen and (min-width: 750px) { + display: grid; + } +} + +.product-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--product-grid-gap); + margin: auto; + padding: 0; + list-style: none; +} + +@media screen and (min-width: 750px) { + .product-grid { + grid-template-columns: var(--product-grid-columns-desktop); + } +} + +.product-grid :is(h3, p) { + margin: 0; +} + +.product-grid__item { + border: var(--product-card-border-width) solid rgb(var(--color-border-rgb) / var(--product-card-border-opacity)); +} + +.product-grid--organic[product-grid-view='default'] .product-grid__item { + height: fit-content; +} + +.product-grid__card.product-grid__card { + display: flex; + flex-flow: column nowrap; + gap: var(--product-card-gap); + align-items: var(--product-card-alignment); + text-decoration: none; + color: var(--color, var(--color-foreground)); + padding-block: var(--padding-block-start) var(--padding-block-end); + padding-inline: var(--padding-inline-start) var(--padding-inline-end); + overflow: hidden; +} + +[product-grid-view='zoom-out'] .product-grid__card { + row-gap: var(--padding-xs); +} + +[product-grid-view='default'] { + --product-grid-gap: 16px; + --padding-block-start: 24px; + --padding-block-end: 24px; + --padding-inline-start: 0px; + --padding-inline-end: 0px; +} + +[product-grid-view='default'] .product-grid__item { + padding-block: 0; +} + +[product-grid-view='mobile-single'], +.product-grid-mobile--large { + @media screen and (max-width: 749px) { + grid-template-columns: 1fr; + } +} + +.product-grid__card .group-block > * { + @media screen and (max-width: 749px) { + flex-direction: column; + } +} + +ul[product-grid-view='zoom-out'] .product-grid__card > * { + display: none; +} + +ul[product-grid-view='zoom-out'] .product-grid__card .card-gallery { + display: block; +} + +[product-grid-view='zoom-out'] + .card-gallery + > :is(quick-add-component, .product-badges, slideshow-component > slideshow-controls) { + display: none; +} + +ul[product-grid-view='zoom-out'] .card-gallery > img { + display: block; +} + +[product-grid-view='zoom-out'] { + --product-grid-columns-desktop: repeat( + 10, + minmax(clamp(50px, calc(100% - 9 * var(--product-grid-gap)) / 10, 80px), 1fr) + ); +} + +.product-grid-view-zoom-out--details { + display: none; +} + +.product-grid-view-zoom-out--details .h4, +.product-grid-view-zoom-out--details span, +.product-grid-view-zoom-out--details s { + font-size: var(--font-size--xs); + font-family: var(--font-paragraph--family); +} + +.product-grid-view-zoom-out--details span { + font-weight: 500; +} + +.product-grid-view-zoom-out--details .h4 { + line-height: 1.3; + font-weight: 400; +} + +.product-grid-view-zoom-out--details > span.h6, +.product-grid-view-zoom-out--details > div.h6 > product-price { + display: inline-block; + line-height: 0; + margin-top: var(--margin-2xs); +} + +.product-grid-view-zoom-out--details > span.h6 > *, +.product-grid-view-zoom-out--details > div.h6 > * > * { + line-height: 1.2; +} + +@media (prefers-reduced-motion: no-preference) { + :root:active-view-transition-type(product-grid) { + details[open] floating-panel-component { + view-transition-name: panel-content; + + .checkbox *, + .facets__pill-label { + transition: none; + } + + .facets--vertical & { + view-transition-name: none; + } + } + + .product-grid { + view-transition-name: product-grid; + } + + footer { + view-transition-name: footer; + } + + .product-grid__item, + floating-panel-component { + transition: none; + } + } +} + +::view-transition-group(panel-content) { + z-index: 1; +} + +::view-transition-new(product-grid) { + animation-delay: 150ms; + animation-name: fadeInUp; + animation-duration: var(--animation-speed); + animation-timing-function: var(--animation-easing); +} + +results-list[initialized] { + .product-grid__item { + transition: opacity var(--animation-speed) var(--animation-easing), + transform var(--animation-speed) var(--animation-easing); + + @starting-style { + opacity: 0; + transform: translateY(10px); + } + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Collection and product list cards have equal heights */ +:is(.product-grid__item, .resource-list__item) .product-card { + display: grid; + height: 100%; +} + +/* Video background */ +.video-background, +.video-background * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +.video-background--cover * { + object-fit: cover; +} + +.video-background--contain * { + object-fit: contain; +} + +.text-block { + width: 100%; +} + +.text-block > *:first-child, +.text-block > *:first-child:empty + * { + margin-block-start: 0; +} + +.text-block > *:last-child, +.text-block > *:has(+ *:last-child:empty) { + margin-block-end: 0; +} + +/* This is to deal with the margin applied to the p when custom styles are enabled. The p isn't the first child anymore due to the style tag */ +.text-block > style + * { + margin-block-start: 0; +} + +/* Dialog */ +.dialog-modal { + border: none; + box-shadow: var(--shadow-popover); + + @media screen and (min-width: 750px) { + border-radius: var(--style-border-radius-popover); + max-width: var(--normal-content-width); + } + + @media screen and (max-width: 749px) { + max-width: 100%; + max-height: 100%; + height: 100dvh; + width: 100dvw; + padding: var(--padding-md); + } +} + +.dialog-modal::backdrop { + transition: backdrop-filter var(--animation-speed) var(--animation-easing); + backdrop-filter: brightness(1); + background: rgb(var(--backdrop-color-rgb) / var(--backdrop-opacity)); +} + +.dialog-modal[open] { + animation: elementSlideInTop var(--animation-speed) var(--animation-easing) forwards; + + &::backdrop { + animation: backdropFilter var(--animation-speed) var(--animation-easing) forwards; + transition: opacity var(--animation-speed) var(--animation-easing); + } +} + +.dialog-modal.dialog-closing { + animation: elementSlideOutTop var(--animation-speed) var(--animation-easing) forwards; + + &::backdrop { + opacity: 0; + } +} + +/* stylelint-disable value-keyword-case */ +.dialog-drawer { + --dialog-drawer-opening-animation: move-and-fade; + --dialog-drawer-closing-animation: move-and-fade; +} + +.dialog-drawer--right { + --dialog-drawer-opening-animation: move-and-fade; + --dialog-drawer-closing-animation: move-and-fade; +} +/* stylelint-enable value-keyword-case */ + +.dialog-drawer[open] { + --start-x: var(--custom-transform-from, 100%); + --end-x: var(--custom-transform-to, 0px); + --start-opacity: 1; + + animation: var(--dialog-drawer-opening-animation) var(--animation-speed) var(--animation-easing) forwards; +} + +.dialog-drawer[open].dialog-closing { + --start-x: 0px; + --end-x: 100%; + --start-opacity: 1; + --end-opacity: 1; + + animation: var(--dialog-drawer-closing-animation) var(--animation-speed) var(--animation-easing); +} + +.dialog-drawer--right[open] { + --start-x: -100%; + --start-opacity: 1; +} + +.dialog-drawer--right[open].dialog-closing { + --start-x: 0px; + --end-x: -100%; + --start-opacity: 1; + --end-opacity: 1; + + animation: var(--dialog-drawer-closing-animation) var(--animation-speed) var(--animation-easing); +} + +/* Buttons */ +.button, +.button-secondary, +button.shopify-payment-button__button--unbranded { + --text-align: center; + + display: grid; + align-content: center; + text-decoration: none; + text-align: var(--text-align); + color: var(--button-color); + appearance: none; + background-color: var(--button-background-color); + border: none; + font-family: var(--font-paragraph--family); + font-style: var(--font-paragraph--style); + font-weight: var(--font-paragraph--weight); + font-size: var(--font-paragraph--size); + line-height: var(--font-paragraph--line-height); + margin-block: 0; + transition: color var(--animation-speed) var(--animation-easing), + box-shadow var(--animation-speed) var(--animation-easing), + background-color var(--animation-speed) var(--animation-easing); + cursor: pointer; + width: fit-content; + box-shadow: inset 0 0 0 var(--button-border-width) var(--button-border-color); + padding-block: var(--button-padding-block); + padding-inline: var(--button-padding-inline); +} + +.button { + font-family: var(--button-font-family-primary); + text-transform: var(--button-text-case-primary); + border-radius: var(--style-border-radius-buttons-primary); +} + +.button:not(.button-secondary, .button-unstyled) { + outline-color: var(--button-background-color); +} + +.button-secondary { + font-family: var(--button-font-family-secondary); + text-transform: var(--button-text-case-secondary); + border-radius: var(--style-border-radius-buttons-secondary); +} + +button.shopify-payment-button__button--unbranded { + font-family: var(--button-font-family-primary); + text-transform: var(--button-text-case-primary); +} + +textarea, +input:not([type='checkbox'], [type='radio']) { + background-color: var(--color-input-background); + border-color: var(--color-input-border); +} + +textarea::placeholder, +input::placeholder { + color: var(--color-input-text); +} + +textarea:not(:placeholder-shown)::placeholder, +input:not(:placeholder-shown)::placeholder { + opacity: 0; +} + +/* The declaration above is messing with buttons that have an attribute of hidden as it overwrites the display value */ +.button[hidden] { + display: none; +} + +.button[aria-disabled='true'], +.button-secondary[aria-disabled='true'], +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.button, +button.shopify-payment-button__button--unbranded { + --button-color: var(--color-primary-button-text); + --button-background-color: var(--color-primary-button-background); + --button-border-color: var(--color-primary-button-border); + --button-border-width: var(--style-border-width-primary); +} + +.button:hover, +button.shopify-payment-button__button--unbranded:hover:not([disabled]) { + --button-color: var(--color-primary-button-hover-text); + --button-background-color: var(--color-primary-button-hover-background); + --button-border-color: var(--color-primary-button-hover-border); +} + +.button-secondary { + --button-color: var(--color-secondary-button-text); + --button-background-color: var(--color-secondary-button-background); + --button-border-color: var(--color-secondary-button-border); + --button-border-width: var(--style-border-width-secondary); +} + +.button-secondary:hover { + --button-color: var(--color-secondary-button-hover-text); + --button-background-color: var(--color-secondary-button-hover-background); + --button-border-color: var(--color-secondary-button-hover-border); +} + +/* Needed to override the default Shopify styles */ +button.shopify-payment-button__button--unbranded:hover:not([disabled]) { + background-color: var(--button-background-color); +} + +.button-unstyled { + display: block; + padding: 0; + background-color: inherit; + color: inherit; + border: 0; + border-radius: 0; + overflow: hidden; + box-shadow: none; + font-family: var(--font-paragraph--family); + font-style: var(--font-paragraph--style); + font-size: var(--font-paragraph--size); +} + +.button-unstyled:hover { + background-color: inherit; +} + +.button-unstyled--with-icon { + color: var(--color-foreground); + display: flex; + gap: var(--gap-2xs); + align-items: center; +} + +.button-unstyled--transparent { + background-color: transparent; + box-shadow: none; +} + +/* Show more */ + +.show-more__button { + color: var(--color-primary); + cursor: pointer; +} + +.show-more__button:hover { + @media screen and (min-width: 750px) { + color: var(--color-primary-hover); + } +} + +.show-more__label { + text-align: start; + font-size: var(--font-size--body-md); + font-family: var(--font-paragraph--family); +} + +.show-more__button .svg-wrapper { + width: var(--icon-size-xs); + height: var(--icon-size-xs); +} + +.show-more[data-expanded='true'] .show-more__label--more, +.show-more[data-expanded='false'] .show-more__label--less { + display: none; +} + +.link { + display: inline-block; + text-align: center; +} + +shopify-accelerated-checkout, +shopify-accelerated-checkout-cart { + --shopify-accelerated-checkout-button-border-radius: var(--style-border-radius-buttons-primary); + --shopify-accelerated-checkout-button-block-size: var(--height-buy-buttons); +} + +.product-form-buttons:has(.add-to-cart-button.button-secondary) + :is(shopify-accelerated-checkout, shopify-accelerated-checkout-cart) { + --shopify-accelerated-checkout-button-border-radius: var(--style-border-radius-buttons-secondary); + --shopify-accelerated-checkout-button-block-size: var(--height-buy-buttons); +} + +/* Collapsible row */ + +.icon-caret svg { + transition: transform var(--animation-speed) var(--animation-easing); +} + +.icon-caret--forward svg { + transform: rotate(-90deg); +} + +.icon-caret--backward svg { + transform: rotate(90deg); +} + +summary { + display: flex; + align-items: center; + cursor: pointer; + list-style: none; + padding-block: var(--padding-sm); +} + +summary:hover { + color: var(--color-primary-hover); +} + +summary .svg-wrapper { + margin-inline-start: auto; + height: var(--icon-size-xs); + width: var(--icon-size-xs); + transition: transform var(--animation-speed) var(--animation-easing); +} + +/* Shared plus/minus icon animations */ +summary .icon-plus :is(.horizontal, .vertical), +.show-more__button .icon-plus :is(.horizontal, .vertical) { + transition: transform var(--animation-speed) var(--animation-easing); + transform: rotate(0deg); + transform-origin: 50% 50%; + opacity: 1; +} + +details[open] > summary .icon-plus .horizontal, +.details-open > summary .icon-plus .horizontal, +.show-more:where([data-expanded='true']) .show-more__button .icon-plus .horizontal { + transform: rotate(90deg); +} + +details[open] > summary .icon-plus .vertical, +.details-open > summary .icon-plus .vertical, +.show-more:where([data-expanded='true']) .show-more__button .icon-plus .vertical { + transform: rotate(90deg); + opacity: 0; +} + +/* Product Media */ +media-gallery { + display: block; + width: 100%; +} + +:where(media-gallery, .product-grid__item) { + .media-gallery__grid { + grid-template-columns: 1fr; + gap: var(--image-gap); + } +} + +.product-media-gallery__slideshow--single-media slideshow-container { + @media screen and (max-width: 749px) { + grid-area: unset; + } +} + +:not(.dialog-zoomed-gallery) > .product-media-container { + --slide-width: 100%; + + display: flex; + aspect-ratio: var(--gallery-aspect-ratio, var(--media-preview-ratio)); + max-height: var(--constrained-height); + width: var(--slide-width, 100%); + + /* Relative position needed for video and 3d models */ + position: relative; + overflow: hidden; + + &:where(.constrain-height) { + /* arbitrary offset value based on average theme spacing and header height */ + --viewport-offset: 400px; + --constrained-min-height: 300px; + --constrained-height: max(var(--constrained-min-height), calc(100vh - var(--viewport-offset))); + + margin-right: auto; + margin-left: auto; + } + + @supports (--test: round(up, 100%, 1px)) { + /* width and overflow forces children to shrink to parent width */ + --slide-width: round(up, 100%, 1px); + } +} + +media-gallery:where(.media-gallery--grid) .media-gallery__grid { + display: none; +} + +media-gallery.media-gallery--grid .media-gallery__grid .product-media-container { + /* Needed for safari to stretch to full grid height */ + height: 100%; +} + +.product-media :is(deferred-media, product-model) { + position: absolute; +} + +@media screen and (max-width: 749px) { + .product-media-container.constrain-height { + max-height: none; + } +} + +@media screen and (min-width: 750px) { + .product-media-container.constrain-height { + --viewport-offset: var(--header-height, 100px); + --constrained-min-height: 500px; + } + + body:has(header-component[transparent]) .product-media-container.constrain-height { + --viewport-offset: 0px; + } + + .media-gallery--two-column .media-gallery__grid { + grid-template-columns: repeat(2, 1fr); + } + + .media-gallery--large-first-image .product-media-container:first-child, + .media-gallery--two-column .product-media-container:only-child { + /* First child spans 2 columns */ + grid-column: span 2; + } + + /* Display grid view as a carousel on mobile, grid on desktop */ + media-gallery:is(.media-gallery--grid) slideshow-component { + display: none; + } + + media-gallery:where(.media-gallery--grid) .media-gallery__grid { + display: grid; + } +} + +.product-media-container--model { + /* Usefull when view in your space is shown */ + flex-direction: column; +} + +.shopify-model-viewer-ui__controls-area { + bottom: calc(var(--minimum-touch-target) + var(--padding-sm)); +} + +.product-media-container img { + aspect-ratio: inherit; + object-fit: contain; +} + +.product-media-container.media-fit-contain img { + object-position: center center; +} + +.product-media-container.media-fit { + --product-media-fit: cover; + + img { + object-fit: var(--product-media-fit); + } +} + +/* Media gallery zoom dialog */ +.product-media-container__zoom-button { + position: absolute; + width: 100%; + height: 100%; + z-index: var(--layer-flat); + cursor: zoom-in; + background-color: transparent; + + &:hover { + background-color: transparent; + } +} + +zoom-dialog dialog { + width: 100vw; + height: 100vh; + border: none; + margin: 0; + padding: 0; + max-width: 100%; + max-height: 100%; + background: #fff; + opacity: 0; + transition: opacity var(--animation-speed) var(--animation-easing); + scrollbar-width: none; + + &[open] { + opacity: 1; + } + + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; + } + + &::backdrop { + background: transparent; + } +} + +/* Animate the UI elements in only after the view transition is complete */ +.close-button { + position: fixed; + top: var(--margin-lg); + right: var(--margin-lg); + width: var(--minimum-touch-target); + height: var(--minimum-touch-target); + z-index: var(--layer-flat); + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + + /* For the outline radius */ + border-radius: 50%; +} + +/* This triggers iOS < 16.4. The outline bug is not recognized as a lack of @supports */ + +@supports not (background-color: rgb(from red 150 g b / alpha)) { + /** + There is a bug in safari < 16.4 that causes the outline to not follow the elements border radius. This is a workaround. + Using element selector to increase specificity. + **/ + + .close-button:focus-visible { + outline: none; + overflow: visible; + } + + .close-button:focus-visible::after { + content: ''; + position: absolute; + inset: calc(-1 * var(--focus-outline-offset)); + border: var(--focus-outline-width) solid currentColor; + border-radius: 50%; + display: inherit; + } +} + +.dialog--closed .close-button { + animation: elementSlideOutBottom calc(var(--animation-speed) * 0.5) var(--animation-easing) forwards; +} + +.dialog-thumbnails-list-container { + position: fixed; + width: 100%; + bottom: 0; + display: flex; + z-index: var(--layer-raised); +} + +.dialog-thumbnails-list { + --active-thumbnail-border-color: rgb(var(--color-border-rgb) / var(--media-border-opacity)); + + position: relative; + display: inline-flex; + flex-direction: row; + gap: 8px; + bottom: 0; + overflow-x: auto; + opacity: 0; + padding: var(--padding-lg); + margin-inline: auto; + scrollbar-width: none; + animation: thumbnailsSlideInBottom calc(var(--animation-speed) * 0.75) var(--animation-easing) forwards; + animation-delay: calc(var(--animation-speed) * 1.5); +} + +.dialog--closed .dialog-thumbnails-list { + animation: thumbnailsSlideOutBottom var(--animation-speed) var(--animation-easing) forwards; +} + +@media screen and (min-width: 750px) { + .dialog-thumbnails-list { + position: fixed; + flex-direction: column; + inset: 50% var(--margin-lg) auto auto; + right: 0; + max-height: calc(100vh - 200px); + overflow-y: auto; + animation: thumbnailsSlideInTop var(--spring-d220-b0-duration) var(--spring-d220-b0-easing) forwards; + animation-delay: calc(var(--spring-d220-b0-duration) * 0.5); + } + + .dialog--closed .dialog-thumbnails-list { + animation: thumbnailsSlideOutTop var(--animation-speed) var(--animation-easing) forwards; + } +} + +.dialog-thumbnails-list__thumbnail { + width: var(--thumbnail-width); + height: auto; + transition: transform var(--animation-speed) var(--animation-easing); + flex-shrink: 0; + border-radius: var(--media-radius); + + img { + height: 100%; + object-fit: cover; + border-radius: var(--media-radius); + aspect-ratio: var(--aspect-ratio); + } + + &:is([aria-selected='true']) { + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: calc(var(--focus-outline-offset) / 2); + border: var(--style-border-width) solid var(--active-thumbnail-border-color); + } +} + +@supports (anchor-name: --test) { + .dialog-thumbnails-list:has(.dialog-thumbnails-list__thumbnail:is([aria-selected='true']))::after { + --inset-offset: calc(var(--focus-outline-offset) / 2); + + content: ''; + position: absolute; + inset: anchor(top) anchor(right) anchor(bottom) anchor(left); + position-anchor: --selected-thumbnail; + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: calc(var(--focus-outline-offset) / 2); + border: var(--style-border-width) solid var(--active-thumbnail-border-color); + border-radius: var(--media-radius); + z-index: var(--layer-raised); + } + + @media (prefers-reduced-motion: no-preference) { + .dialog-thumbnails-list:has(.dialog-thumbnails-list__thumbnail:is([aria-selected='true']))::after { + transition-property: inset; + transition-duration: var(--spring-d180-b0-duration); + transition-timing-function: var(--spring-d180-b0-easing); + } + } + + .dialog-thumbnails-list__thumbnail:is([aria-selected='true']) { + outline: none; + border: none; + anchor-name: --selected-thumbnail; + } +} + +.close-button:hover { + background-color: transparent; + opacity: 0.8; +} + +.close-button svg { + width: var(--icon-size-xs); + height: var(--icon-size-xs); +} + +/* Product media */ +.product-media { + display: flex; + flex: 1; +} + +/* If the product media is already providing an image cover, hide images provided by sibling deferred-media */ +.product-media__image ~ * .deferred-media__poster-image { + display: none; +} + +/* If the product media is playing, hide the preview image */ +.product-media-container:has(.deferred-media__playing) .product-media__image { + opacity: 0; + transition: opacity var(--animation-speed) var(--animation-easing); +} + +/* Deferred media & Product model */ +:is(product-model, deferred-media) { + /* Height needed to make sure when it's set to be stretched, it takes the full height */ + height: 100%; + width: 100%; + position: relative; +} + +product-model model-viewer, +/* Media that have a poster button sibling providing the size should be absolute-positioned. +Otherwise, it should be a block to rely on its own size */ +:is(deferred-media, product-model) > .deferred-media__poster-button ~ *:not(template) { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + + /* Required to make sure the absolute position respects the padding of the wrapper: */ + padding: inherit; +} + +slideshow-slide .shopify-model-viewer-ui__controls-area.shopify-model-viewer-ui__controls-area { + bottom: var(--padding-sm); + right: var(--padding-sm); +} + +.dialog-zoomed-gallery .shopify-model-viewer-ui__controls-area.shopify-model-viewer-ui__controls-area { + /* Move the controls above the thumbnails. Need to calculate the height of the thumbnails list */ + bottom: calc(var(--thumbnail-width) / var(--media-preview-ratio) + var(--padding-lg) * 2); + right: var(--padding-lg); +} + +@media screen and (max-width: 749px) { + slideshow-component:has(:not(.mobile\:hidden) :is(.slideshow-controls__dots, .slideshow-controls__counter)) + .shopify-model-viewer-ui__controls-area { + /* Position the controls just above the counter */ + bottom: calc(var(--minimum-touch-target) + var(--padding-sm)); + } +} + +@media screen and (min-width: 750px) { + slideshow-component:has(:not(.desktop\:hidden) :is(.slideshow-controls__dots, .slideshow-controls__counter)) + .shopify-model-viewer-ui__controls-area { + /* Position the controls just above the counter */ + bottom: calc(var(--minimum-touch-target) + var(--padding-sm)); + } + + .dialog-zoomed-gallery .shopify-model-viewer-ui__controls-area.shopify-model-viewer-ui__controls-area { + /* Move the controls up to match the padding on the thumbnails */ + bottom: var(--padding-lg); + + /* Move the controls to the left of the thumbnails list on the right */ + right: calc(var(--thumbnail-width) + var(--padding-lg) * 2); + } +} + +:is(deferred-media, .video-placeholder-wrapper).border-style { + /* Apply the border radius to the video */ + overflow: hidden; +} + +deferred-media { + /* The overflow hidden in the deferred-media won't let the button show the focus ring */ + &:has(:focus-visible) { + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: var(--focus-outline-offset); + } + + @supports not selector(:focus-visible) { + &:has(:focus) { + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: var(--focus-outline-offset); + } + } +} + +.deferred-media__poster-button { + width: 100%; + height: 100%; + aspect-ratio: var(--video-aspect-ratio, auto); +} + +.deferred-media__poster-button.deferred-media__playing { + opacity: 0; + transition: opacity 0.3s ease; +} + +deferred-media img { + height: 100%; + object-fit: cover; + transition: opacity 0.3s ease; +} + +deferred-media iframe { + display: block; + width: 100%; + height: 100%; + border: none; + aspect-ratio: var(--size-style-aspect-ratio, auto); +} + +deferred-media[data-media-loaded] img { + opacity: 0; +} + +.deferred-media__poster-icon, +.video-placeholder-wrapper__poster-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.deferred-media__poster-icon svg, +.video-placeholder-wrapper__poster-icon svg { + width: var(--button-size); + height: var(--button-size); + color: var(--color-white); + filter: drop-shadow(var(--shadow-button)); + + &:hover { + color: rgb(var(--color-white-rgb) / var(--opacity-80)); + } + + @media screen and (min-width: 750px) { + width: 4rem; + height: 4rem; + } +} + +deferred-media[class] :is(.deferred-media__poster-button img, .deferred-media__poster-button ~ video) { + /* only apply this on the video block not product media */ + object-fit: cover; + height: 100%; + aspect-ratio: var(--size-style-aspect-ratio, auto); +} + +.button-shopify-xr { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: var(--padding-md); +} + +.button-shopify-xr > svg { + width: var(--icon-size-sm); + height: var(--icon-size-sm); + margin-inline-end: var(--margin-md); +} + +.button-shopify-xr[data-shopify-xr-hidden] { + display: none; +} + +/* Swatches */ +.swatch { + --color-border: rgb(var(--color-foreground-rgb) / var(--style-border-swatch-opacity)); + --min-width-unitless: 15.9999; /* want to avoid division by 0 */ + --min-height-unitless: 15.9999; /* want to avoid division by 0 */ + --min-height: 16px; + --min-width: 16px; + + /* mobile values */ + --scaling-factor: 0.5; + --max-swatch-size: 28px; + --max-pill-size: 20px; + --max-filter-size: 32px; + + /* From the settings */ + --offset-swatch-width: calc(var(--variant-picker-swatch-width-unitless) - var(--min-width-unitless)); + --offset-swatch-height: calc(var(--variant-picker-swatch-height-unitless) - var(--min-height-unitless)); + + /** + Offset values are obtained from the following formulas: + offset-width = width - min-width + offset-height = height - min-height + + The offset-scaled-width and heigth are obtained by extending the line from + [min,min] to [W,H] and taking the intersection with a square that starts at + [min,min] and ends at [max,max]. + + The extending line forms right angle triangles with the [min,min]->[max,max] + box that enable us to derive the following formulas + + We also want the result to always be smaller than the input (pdp > everywhere else) + by some scaling factor. + */ + --offset-scaled-width: calc( + var(--scaling-factor) * var(--offset-swatch-width) / var(--offset-swatch-height) * var(--offset-max-swatch-size) + ); + --offset-scaled-height: calc( + var(--scaling-factor) * var(--offset-swatch-height) / var(--offset-swatch-width) * var(--offset-max-swatch-size) + ); + --offset-max-swatch-size: calc(var(--max-swatch-size) - var(--min-width)); + + /* width = min(m + sU, (m + s * W'/H' * M'), M) */ + --swatch-width: min( + calc(var(--min-width) + var(--scaling-factor) * var(--offset-swatch-width) * 1px), + calc(var(--min-width) + var(--offset-scaled-width)), + var(--max-swatch-size) + ); + + /* height = min(m + sV, (m + s * H'/W' * M'), M) */ + --swatch-height: min( + calc(var(--min-height) + var(--scaling-factor) * var(--offset-swatch-height) * 1px), + calc(var(--min-height) + var(--offset-scaled-height)), + var(--max-swatch-size) + ); + + display: block; + background: var(--swatch-background); + background-position: var(--swatch-focal-point, center); + border-radius: var(--variant-picker-swatch-radius); + border: var(--style-border-swatch-width) var(--style-border-swatch-style) var(--color-border); + width: var(--swatch-width); + height: var(--swatch-height); + + /* This is different than `background-size: cover` because we use `box-sizing: border-box`, + * doing it like makes the background clip under the border without repeating. + */ + background-size: var(--swatch-width) var(--swatch-height); + + &.swatch--unavailable { + border-style: dashed; + } + + &.swatch--unscaled { + /* for when you want fixed sizing (e.g. pdp) */ + --swatch-width: var(--variant-picker-swatch-width); + --swatch-height: var(--variant-picker-swatch-height); + } + + &.swatch--filter { + --swatch-width: var(--max-filter-size); + --swatch-height: var(--max-filter-size); + + border-radius: var(--variant-picker-swatch-radius); + } + + &.swatch--pill { + --swatch-width: var(--max-pill-size); + --swatch-height: var(--max-pill-size); + + border-radius: var(--variant-picker-swatch-radius); + } + + /* swatches in filters and pills always have a border */ + &.swatch--filter, + &.swatch--pill { + --style-border-swatch-width: var(--variant-picker-border-width); + --style-border-swatch-style: var(--variant-picker-border-style); + --color-border: rgb(var(--color-foreground-rgb) / var(--variant-picker-border-opacity)); + } + + &.swatch--variant-image { + background-size: cover; + } + + @media screen and (min-width: 750px) { + /* desktop values */ + --max-swatch-size: 32px; + --max-pill-size: 16px; + --max-filter-size: 28px; + --scaling-factor: 0.65; + } +} + +.variant-picker .variant-option--buttons label:has(.swatch) { + border-radius: var(--variant-picker-swatch-radius); +} + +/* Variant option component */ +.variant-option { + --options-border-radius: var(--variant-picker-button-radius); + --options-border-width: var(--variant-picker-button-border-width); + --variant-option-padding-inline: var(--padding-md); +} + +.variant-option + .variant-option { + margin-top: var(--padding-lg); +} + +.variant-option--swatches { + --options-border-radius: var(--variant-picker-swatch-radius); + + width: 100%; + + overflow-list::part(list) { + padding-block: var(--overflow-list-padding-block, 0); + padding-inline: var(--overflow-list-padding-inline, 0); + } +} + +.variant-option--swatches > overflow-list { + justify-content: var(--product-swatches-alignment); + + @media screen and (max-width: 749px) { + justify-content: var(--product-swatches-alignment-mobile); + } +} + +.variant-option--buttons { + display: flex; + flex-wrap: wrap; + gap: var(--gap-sm); + margin: 0; + padding: 0; + border: none; +} + +.variant-option--buttons legend { + padding: 0; + margin-block-end: var(--margin-xs); +} + +.variant-option__swatch-value { + padding-inline-start: var(--padding-xs); + color: rgb(var(--color-foreground-rgb) / var(--opacity-70)); +} + +@media (prefers-reduced-motion: no-preference) { + .variant-option__button-label, + .variant-option__select-wrapper, + .variant-option__button-label::before, + .variant-option__button-label::after, + .variant-option__button-label:has([data-previous-checked='true'], [data-current-checked='true']) + .variant-option__button-label__pill, + .variant-option__button-label:not(.variant-option__button-label--has-swatch) svg line:last-of-type { + transition-duration: var(--animation-speed); + transition-timing-function: var(--animation-easing); + } + + .variant-option__button-label__pill { + transition-property: transform; + } + + .variant-option__button-label:not(.variant-option__button-label--has-swatch) svg line:last-of-type { + transition-property: clip-path; + } + + .variant-option__button-label:has([data-previous-checked='true'], [data-current-checked='true']) + .variant-option__button-label__pill { + transition-property: transform; + } + + .variant-option__button-label::after { + transition-property: clip-path; + } + + .variant-option__button-label::before { + transition-property: border-color; + } + + .variant-option__select-wrapper, + .variant-option__button-label { + transition-property: background-color, border-color, color; + } +} + +.variant-option__button-label { + --variant-picker-stroke-color: var(--color-variant-border); + + cursor: pointer; + display: flex; + flex: 0 0 3.25em; + align-items: center; + position: relative; + padding-block: var(--padding-sm); + padding-inline: var(--padding-lg); + border: var(--options-border-width) solid var(--color-variant-border); + border-radius: var(--options-border-radius); + overflow: clip; + justify-content: center; + min-height: 3.25em; + min-width: fit-content; + white-space: nowrap; + background-color: var(--color-variant-background); + color: var(--color-variant-text); + gap: 0; + + &:hover, + &:hover:has([aria-disabled='true']):has([data-option-available='false']) { + background-color: var(--color-variant-hover-background); + border-color: var(--color-variant-hover-border); + color: var(--color-variant-hover-text); + } + + /* we need something like overflow-clip-margin to use the pseudoelement but it doesn't work in Safari */ + + /* so instead use the layered background image trick */ + &:not(.variant-option__button-label--has-swatch):has([data-option-available='false']) { + border-width: 0; + } + + /* ::after/::before act as a fake border for the button style variant */ + + /* ::after is the unavailable variant border that clips in */ + &:not(.variant-option__button-label--has-swatch)::before, + &:has([data-option-available='false']):not(.variant-option__button-label--has-swatch)::after { + content: ''; + position: absolute; + inset: 0; + border: var(--options-border-width) solid var(--color-selected-variant-border); + border-radius: inherit; + pointer-events: none; + z-index: 2; + /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ + clip-path: inset(var(--clip, 0 0 0 0)); + } + + &:has([data-option-available='false']):not(.variant-option__button-label--has-swatch)::before { + inset: 0; + } + + &:not(.variant-option__button-label--has-swatch)::before { + /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ + clip-path: inset(0 0 0 0); + border-color: var(--color-variant-border); + inset: calc(var(--options-border-width) * -1); + } + + &:has(:checked):not(.variant-option__button-label--has-swatch, :has([data-option-available='false']))::before { + border-color: var(--color-selected-variant-border); + } + + /* setting left/right accounts for variant buttons of different widths */ + &:not(:has(:checked)):has(~ label > :checked), + &:has(:checked):has(~ label > [data-previous-checked='true']) { + .variant-option__button-label__pill { + right: 0; + left: unset; + } + } + + &:has([data-previous-checked='true']) ~ label:has([data-current-checked='true']), + &:has(:checked) ~ label { + .variant-option__button-label__pill { + left: 0; + right: unset; + } + } + + &:not(:has(:checked)):has(~ label > :checked) { + --pill-offset: calc(100% + 1px); + } + + &:has(:checked) ~ label { + --pill-offset: calc(-100% - 1px); + } + + &:has([data-current-checked='true']):first-of-type + ~ label:last-of-type:not(.variant-option__button-label--has-swatch), + &:not(:has(:checked)):has(~ label > :checked):not(.variant-option__button-label--has-swatch) { + --clip: 0 0 0 100%; + } + + &:not(:has([data-current-checked='true'])):first-of-type:has(~ label:last-of-type > :checked):not( + .variant-option__button-label--has-swatch + ), + &:has(:checked) ~ label:not(.variant-option__button-label--has-swatch) { + --clip: 0 100% 0 0; + } + + &:has([data-previous-checked='true'], [data-current-checked='true']) .variant-option__button-label__pill { + width: max(var(--pill-width-current, 100%), var(--pill-width-previous, 100%)); + } + + @media screen and (min-width: 750px) { + padding: var(--padding-xs) var(--variant-option-padding-inline); + } +} + +/* wrap around only for 3 or more variants in a row */ + +/* the more complex selector rules here produce the wrap around effect for first/last variants */ +.variant-option--buttons:has(:nth-of-type(3)) { + .variant-option__button-label:has([data-current-checked='true']):first-of-type ~ label:last-of-type { + --pill-offset: calc(100% + 1px); + } + + .variant-option__button-label:not(:has([data-current-checked='true'])):first-of-type:has( + ~ label:last-of-type > :checked + ) { + --pill-offset: calc(-100% - 1px); + } +} + +.variant-option__button-label__pill { + background: var(--color-selected-variant-background); + position: absolute; + top: calc(var(--options-border-width) * -1); + bottom: calc(var(--options-border-width) * -1); + border-radius: inherit; + pointer-events: none; + width: 100%; + transform: translateX(var(--pill-offset, 0)); +} + +.variant-option__button-label__text { + pointer-events: none; + text-align: start; + text-wrap: auto; + z-index: 2; +} + +.variant-option--equal-width-buttons { + --variant-min-width: clamp(44px, calc(var(--variant-option-padding-inline) * 2 + var(--variant-ch)), 100%); + + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--variant-min-width), 1fr)); + + .variant-option__button-label { + min-width: var(--variant-min-width); + } + + .variant-option__button-label__text { + text-align: center; + text-wrap: balance; + } +} + +.variant-option__button-label:has(:focus-visible) { + --variant-picker-stroke-color: var(--color-foreground); + + border-color: var(--color-foreground); + outline: var(--focus-outline-width) solid var(--color-foreground); + outline-offset: var(--focus-outline-offset); +} + +.variant-option__button-label--has-swatch { + --focus-outline-radius: var(--variant-picker-swatch-radius); + + padding: 0; + border: none; + flex-basis: auto; + min-height: auto; +} + +/* Override global label:has(input) display rule with higher specificity */ +.variant-option__button-label--has-swatch:has(input) { + display: block; +} + +.variant-option__button-label:has(:checked) { + color: var(--color-selected-variant-text); + border-color: var(--color-selected-variant-border); +} + +.variant-option__button-label:has(:checked):hover { + border-color: var(--color-selected-variant-hover-border); + color: var(--color-selected-variant-hover-text); + + .variant-option__button-label__pill { + background-color: var(--color-selected-variant-hover-background); + } +} + +.variant-option__button-label:has([data-option-available='false']) { + color: rgb(var(--color-variant-text-rgb) / var(--opacity-60)); +} + +.variant-option__button-label--has-swatch:hover { + outline: var(--focus-outline-width) solid rgb(var(--color-foreground-rgb) / var(--opacity-35-55)); + outline-offset: var(--focus-outline-offset); +} + +.variant-option__button-label--has-swatch:has(:checked) { + --focus-outline: var(--focus-outline-width) solid var(--color-foreground); + + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); +} + +/* This triggers iOS < 16.4. The outline bug is not recognized as a lack of @supports */ +@supports not (background-color: rgb(from red 150 g b / alpha)) { + /** There is a bug in safari < 16.4 that causes the outline to not follow the elements border radius. This is a workaround. **/ + .variant-option__button-label--has-swatch:has(:checked), + .variant-option__button-label:has(:focus-visible) .swatch { + outline: none; + position: relative; + overflow: visible; + } + + .variant-option__button-label--has-swatch:has(:checked)::after, + .variant-option__button-label:has(:focus-visible) .swatch::after { + content: ''; + position: absolute; + inset: calc(-1 * var(--focus-outline-offset)); + border: var(--focus-outline); + border-radius: var(--focus-outline-radius, 50%); + background-color: transparent; + display: inherit; + } +} + +.variant-option__button-label:has([data-option-available='false']):has(:checked) { + background-color: inherit; + color: rgb(var(--color-variant-text-rgb) / var(--opacity-60)); +} + +.variant-option__button-label input { + /* remove the checkbox from the page flow */ + position: absolute; + + /* set the dimensions to match those of the label */ + inset: 0; + + /* hide it */ + opacity: 0; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + aspect-ratio: unset; + border: none; + border-radius: 0; + background: transparent; + appearance: auto; + display: block; + cursor: pointer; +} + +.variant-option__button-label svg { + position: absolute; + left: var(--options-border-width); + top: var(--options-border-width); + height: calc(100% - (var(--options-border-width) * 2)); + width: calc(100% - (var(--options-border-width) * 2)); + cursor: pointer; + pointer-events: none; + stroke-width: var(--style-border-width); + stroke: var(--variant-picker-stroke-color); +} + +.variant-option__button-label:not(.variant-option__button-label--has-swatch) svg { + stroke: var(--color-variant-border); + + line { + stroke-width: var(--options-border-width); + } + + line:last-of-type { + /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ + clip-path: inset(var(--clip, 0 0 0 0)); + stroke: rgb(var(--color-variant-text-rgb) / 1); + } +} + +.sticky-content { + position: sticky; + top: var(--sticky-header-offset, 0); + z-index: var(--layer-flat); +} + +@media screen and (min-width: 750px) { + .sticky-content--desktop, + .sticky-content--desktop.full-height--desktop > .group-block { + position: sticky; + top: var(--sticky-header-offset, 0); + z-index: var(--layer-flat); + } +} + +.price, +.compare-at-price, +.unit-price { + white-space: nowrap; +} + +.unit-price { + display: block; + font-size: min(0.85em, var(--font-paragraph--size)); + color: rgb(var(--color-foreground-rgb) / var(--opacity-subdued-text)); +} + +.tax-note.tax-note.tax-note { + font-size: min(0.85em, var(--font-paragraph--size)); + font-weight: var(--font-paragraph--weight); + color: rgb(var(--color-foreground-rgb) / var(--opacity-subdued-text)); +} + +product-price.text-block:is(.h1, .h2, .h3, .h4, .h5, .h6) > *:not(.tax-note) { + margin-block: 0; +} + +.compare-at-price { + opacity: 0.4; + text-decoration-line: line-through; + text-decoration-thickness: 1.5px; +} + +.card-gallery { + position: relative; +} + +@container (max-width: 70px) { + .card-gallery:hover .quick-add__button { + display: none; + } +} + +/* Hide "Add" button when "Choose" button is shown */ +[data-quick-add-button='choose'] add-to-cart-component { + display: none; +} + +/* Hide "Choose" button when "Add" button is shown */ +[data-quick-add-button='add'] .quick-add__button--choose { + display: none; +} + +/* Drawer */ +.drawer { + background-color: var(--color-background); + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: var(--sidebar-width); + z-index: var(--layer-raised); + transform: translateX(-120%); + transition: transform var(--animation-speed) var(--animation-easing); +} + +.drawer[data-open='true'] { + transform: translateX(0); +} + +.drawer-toggle { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.drawer__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--drawer-header-block-padding) var(--drawer-inline-padding); +} + +.drawer__title { + font-size: var(--font-h2--size); + margin: 0; +} + +.drawer__close { + width: var(--minimum-touch-target); + height: var(--minimum-touch-target); +} + +.drawer__content { + display: block; + padding: var(--drawer-content-block-padding) var(--drawer-inline-padding); + width: 100%; +} + +/* Background overlay */ +.background-overlay { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--background-overlay-color, rgb(0 0 0 / 15%)); + } +} + +/* Spacing style */ +.spacing-style { + --spacing-scale: var(--spacing-scale-md); + + @media screen and (min-width: 990px) { + --spacing-scale: var(--spacing-scale-default); + } + + /* Must disable this, when you use these with calc and another unit type, things break — see logo.liquid */ + /* stylelint-disable length-zero-no-unit */ + --padding-block: 0px; + --padding-block-start: var(--padding-block, 0px); + --padding-block-end: var(--padding-block, 0px); + --padding-inline: 0px; + --padding-inline-start: var(--padding-inline, 0px); + --padding-inline-end: var(--padding-inline, 0px); + --margin-block: 0px; + --margin-block-start: var(--margin-block, 0px); + --margin-block-end: var(--margin-block, 0px); + --margin-inline: 0px; + --margin-inline-start: var(--margin-inline, 0px); + --margin-inline-end: var(--margin-inline, 0px); +} + +.spacing-style, +.inherit-spacing { + padding-block: calc(var(--padding-block-start) + var(--section-top-offset, 0px)) var(--padding-block-end); + padding-inline: var(--padding-inline-start) var(--padding-inline-end); + margin-block: var(--margin-block-start) var(--margin-block-end); + margin-inline: var(--margin-inline-start) var(--margin-inline-end); +} + +/* Size style */ +.size-style { + width: var(--size-style-width-mobile, var(--size-style-width)); + height: var(--size-style-height-mobile, var(--size-style-height)); + + @media screen and (min-width: 750px) { + width: var(--size-style-width); + height: var(--size-style-height); + } +} + +/* Custom Typography style */ +.custom-typography, +.custom-typography > * { + font-family: var(--font-family); + font-weight: var(--font-weight); + text-transform: var(--text-transform); + text-wrap: var(--text-wrap); + line-height: var(--line-height); + letter-spacing: var(--letter-spacing); +} + +.custom-typography { + h1 { + line-height: var(--line-height--display, var(--line-height)); + } + + h2, + h3, + h4 { + line-height: var(--line-height--heading, var(--line-height)); + } + + p { + line-height: var(--line-height--body, var(--line-height)); + } +} + +.custom-font-size, +.custom-font-size > * { + font-size: var(--font-size); +} + +.custom-font-weight, +.custom-font-weight > * { + font-weight: var(--font-weight); +} + +/* Border override style */ +.border-style { + border-width: var(--border-width); + border-style: var(--border-style); + border-color: var(--border-color); + border-radius: var(--border-radius); +} + +/* Gap scaling style */ +.gap-style, +.layout-panel-flex { + --gap-scale: var(--spacing-scale-md); + + @media screen and (min-width: 990px) { + --gap-scale: var(--spacing-scale-default); + } +} + +.layout-panel-flex { + display: flex; + gap: var(--gap); + height: 100%; +} + +.layout-panel-flex--row { + flex-flow: row var(--flex-wrap); + justify-content: var(--horizontal-alignment); + align-items: var(--vertical-alignment); +} + +.layout-panel-flex--column { + flex-flow: column var(--flex-wrap); + align-items: var(--horizontal-alignment); + justify-content: var(--vertical-alignment); +} + +@media screen and (max-width: 749px) { + .mobile-column { + flex-flow: column nowrap; + align-items: var(--horizontal-alignment); + justify-content: var(--vertical-alignment-mobile); + } + + .layout-panel-flex--row:not(.mobile-column) { + flex-wrap: var(--flex-wrap-mobile); + + > .menu { + flex: 1 1 min-content; + } + + > .text-block { + flex: 1 1 var(--max-width--display-tight); + } + + > .image-block { + flex: 1 1 var(--size-style-width-mobile-min); + } + + > .button { + flex: 0 0 fit-content; + } + } +} + +@media screen and (min-width: 750px) { + .layout-panel-flex { + flex-direction: var(--flex-direction); + } +} + +/* Form fields */ +.field { + position: relative; + width: 100%; + display: flex; + transition: box-shadow var(--animation-speed) ease; +} + +.field__input { + flex-grow: 1; + text-align: left; + border-radius: var(--style-border-radius-inputs); + transition: box-shadow var(--animation-speed) ease, background-color var(--animation-speed) ease; + padding: var(--input-padding); + box-shadow: var(--input-box-shadow); + background-color: var(--color-input-background); + color: var(--color-input-text); + border: none; + outline: none; + font-size: var(--font-paragraph--size); + + &:autofill { + background-color: var(--color-input-background); + color: var(--color-input-text); + } +} + +.field__input:is(:focus, :hover) { + box-shadow: var(--input-box-shadow-focus); + background-color: var(--color-input-hover-background); +} + +.field__input--button-radius { + border-radius: var(--style-border-radius-buttons-primary); +} + +.field__input--button-padding { + padding-inline: var(--padding-3xl); +} + +.field__label { + color: rgb(var(--color-input-text-rgb) / var(--opacity-80)); + font-size: var(--font-paragraph--size); + left: var(--input-padding-x); + top: 50%; + transform: translateY(-50%); + margin-bottom: 0; + pointer-events: none; + position: absolute; + transition: top var(--animation-speed) ease, font-size var(--animation-speed) ease; +} + +/* RTE styles */ +.rte, +.shopify-policy__title { + :is(h1, h2, h3, h4, h5, h6) { + margin-block: clamp(1.5rem, 1em * 3.3, 2.5rem) clamp(1rem, 1em * 0.25, 2rem); + } + + :first-child:is(p, h1, h2, h3, h4, h5, h6), + :first-child:empty + :is(p, h1, h2, h3, h4, h5, h6) { + margin-block-start: 0; + } + + ul, + ol { + margin-block-start: 0; + padding-inline-start: 1.5em; + } + + /* Only apply margin-block-end to the higher level list, not nested lists */ + :is(ul, ol):not(:is(ul, ol) :is(ul, ol)) { + margin-block-end: 1em; + } + + blockquote { + margin-inline: 1.5em 2.3em; + margin-block: 3.8em; + padding-inline-start: 0.8em; + border-inline-start: 1.5px solid rgb(var(--color-foreground-rgb) / var(--opacity-25)); + font-style: italic; + font-weight: 500; + } + + .rte-table-wrapper { + overflow-x: auto; + } + + table { + /* stylelint-disable-next-line declaration-no-important */ + width: 100% !important; + border-collapse: collapse; + } + + tr:not(:has(td)), + thead { + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-5)); + font-weight: bold; + text-transform: uppercase; + } + + tr:has(td) { + border-bottom: 1px solid rgb(var(--color-foreground-rgb) / var(--opacity-10)); + } + + th, + td { + text-align: start; + padding-inline: var(--padding-md); + padding-block: var(--padding-sm); + } +} + +.shopify-policy__container { + padding-block: var(--padding-xl); +} + +.checkbox { + --checkbox-top: 50%; + --checkbox-left: 1.5px; + --checkbox-offset: 3px; + --checkbox-path-opacity: 0; + --checkbox-cursor: pointer; + + position: relative; + display: flex; + align-items: center; + + &:has(.checkbox__input:checked) { + --checkbox-path-opacity: 1; + } + + &.checkbox--disabled { + --checkbox-cursor: not-allowed; + } +} + +.checkbox__input { + position: absolute; + opacity: 0; + margin: 0; + padding: 0; + width: var(--checkbox-size); + height: var(--checkbox-size); + aspect-ratio: unset; + border: none; + border-radius: 0; + background: transparent; + appearance: auto; + display: block; + cursor: pointer; + + /* Outline is on the SVG instead, to allow it to have border-radius */ + &:focus-visible { + outline: none; + } + + &:focus-visible + .checkbox__label .icon-checkmark { + outline: var(--focus-outline-width) solid currentcolor; + outline-offset: var(--focus-outline-offset); + } + + &:checked + .checkbox__label .icon-checkmark { + background-color: var(--color-foreground); + border-color: var(--color-foreground); + } + + &:disabled + .checkbox__label .icon-checkmark { + background-color: var(--input-disabled-background-color); + border-color: var(--input-disabled-border-color); + } +} + +.checkbox__label { + position: relative; + display: inline-flex; + cursor: var(--checkbox-cursor); + line-height: var(--checkbox-size); + min-width: var(--minimum-touch-target); +} + +.checkbox .icon-checkmark { + height: var(--checkbox-size); + width: var(--checkbox-size); + flex-shrink: 0; + border: var(--checkbox-border); + border-radius: var(--checkbox-border-radius); + background-color: var(--color-background); +} + +.checkbox__label-text { + padding-inline-start: var(--checkbox-label-padding); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.checkbox .icon-checkmark path { + stroke: var(--color-background); + opacity: var(--checkbox-path-opacity); + transition: opacity var(--animation-speed) var(--animation-easing); +} + +.checkbox__input:disabled + .checkbox__label { + color: var(--input-disabled-text-color); +} + +/* Radio buttons and checkboxes - shared base styles */ +:where(input[type='radio']), +:where(input[type='checkbox']) { + width: var(--checkbox-size); + height: var(--checkbox-size); + aspect-ratio: 1; + margin: 0; + margin-inline-end: var(--padding-3xs); + padding: 0; + border: var(--checkbox-border); + appearance: none; + position: relative; + display: inline-block; + vertical-align: middle; + cursor: pointer; +} + +/* Radio buttons */ +input[type='radio'] { + border-radius: var(--style-border-radius-50); + background: transparent; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +:where(input[type='radio']):checked { + border-color: var(--color-foreground); + background: var(--color-background); +} + +:where(input[type='radio']):checked::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(var(--checkbox-size) / 2); + height: calc(var(--checkbox-size) / 2); + background: var(--color-foreground); + border-radius: var(--style-border-radius-50); + transition: background 0.2s ease; +} + +:where(input[type='radio']):disabled { + border-color: var(--input-disabled-border-color); + background-color: var(--input-disabled-background-color); + cursor: not-allowed; +} + +:where(input[type='radio']):disabled:checked::after { + background: var(--input-disabled-background-color); +} + +:where(input[type='radio']):not(:disabled):hover { + border-color: rgb(var(--color-foreground-rgb) / var(--opacity-40-60)); + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-5)); +} + +:where(input[type='radio']):not(:disabled):hover:checked { + border-color: var(--color-foreground); + background-color: var(--color-background); +} + +:where(input[type='radio']):not(:disabled):hover:checked::after { + background: rgb(var(--color-foreground-rgb) / var(--opacity-85)); +} + +/* Checkboxes */ +:where(input[type='checkbox']) { + border-radius: var(--checkbox-border-radius); + background-color: var(--color-background); + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +:where(input[type='checkbox']):checked { + background-color: var(--color-foreground); + border-color: var(--color-foreground); +} + +:where(input[type='checkbox']):checked::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: var(--checkbox-size); + height: var(--checkbox-size); + background-color: var(--color-background); + mask-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.75439 10.7485L7.68601 14.5888C7.79288 14.7288 7.84632 14.7988 7.91174 14.8242C7.96907 14.8466 8.03262 14.8469 8.09022 14.8253C8.15596 14.8007 8.21026 14.7314 8.31886 14.5927L15.2475 5.74658' stroke='black' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; +} + +:where(input[type='checkbox']):not(:disabled):hover { + border-color: rgb(var(--color-foreground-rgb) / var(--opacity-40-60)); + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-5)); +} + +:where(input[type='checkbox']):not(:disabled):hover:checked { + border-color: var(--color-foreground); + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-85)); +} + +:where(input[type='checkbox']):disabled { + background-color: var(--input-disabled-background-color); + border-color: var(--input-disabled-border-color); + cursor: not-allowed; +} + +:where(input[type='checkbox']):disabled:checked::after { + background-color: var(--input-disabled-text-color); +} + +/* Shared styles for radio buttons and checkboxes */ +:where(input[type='radio']) + label, +:where(input[type='checkbox']) + label { + display: inline; + vertical-align: middle; + cursor: pointer; +} + +:where(input[type='radio']):disabled + label, +:where(input[type='checkbox']):disabled + label { + color: var(--input-disabled-text-color); + cursor: not-allowed; +} + +/* Flexbox for labels wrapping radio buttons or checkboxes */ +label:has(input[type='radio']), +label:has(input[type='checkbox']) { + display: inline-flex; + align-items: center; + gap: var(--padding-2xs); + cursor: pointer; +} + +label:has(input[type='radio']:disabled), +label:has(input[type='checkbox']:disabled) { + cursor: not-allowed; +} + +/* Override for swatch labels to maintain block display */ +.variant-option__button-label--has-swatch:has(input[type='radio']) { + display: block; +} + +/* Add to cart button */ +.button[id^='BuyButtons-ProductSubmitButton-'] { + position: relative; + overflow: hidden; +} + +/* Cart items component */ +.cart-items-component { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +/* Cart bubble */ +.cart-bubble { + --cart-padding: 0.2em; + + position: relative; + width: 20px; + aspect-ratio: 1; + border-radius: 50%; + border-width: 0; + display: flex; + line-height: normal; + align-items: center; + justify-content: center; + color: var(--color-primary-button-text); + padding-inline: var(--cart-padding); +} + +.cart-bubble[data-maintain-ratio] { + aspect-ratio: 1; +} + +.cart-bubble[data-maintain-ratio] .cart-bubble__background { + border-radius: var(--style-border-radius-50); +} + +.cart-bubble__background { + position: absolute; + inset: 0; + background-color: var(--color-primary-button-background); + border-radius: var(--style-border-radius-lg); +} + +.cart-bubble__text { + font-size: var(--font-size--3xs); + z-index: var(--layer-flat); + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +/* Cart typography */ +.cart-primary-typography { + font-family: var(--cart-primary-font-family); + font-style: var(--cart-primary-font-style); + font-weight: var(--cart-primary-font-weight); +} + +.cart-secondary-typography { + font-family: var(--cart-secondary-font-family); + font-style: var(--cart-secondary-font-style); + font-weight: var(--cart-secondary-font-weight); +} + +/* Quantity selector */ +.quantity-selector { + --quantity-selector-width: 124px; + + display: flex; + justify-content: space-between; + align-items: center; + color: var(--color-input-text); + background-color: var(--color-input-background); + border: var(--style-border-width-inputs) solid var(--color-input-border); + border-radius: var(--style-border-radius-inputs); + flex: 1 1 var(--quantity-selector-width); + align-self: stretch; + transition: background-color var(--animation-speed) var(--animation-easing); + + &:hover { + background-color: var(--color-input-hover-background); + } +} + +.product-form-buttons:has(.add-to-cart-button.button-secondary) .quantity-selector { + border-radius: var(--style-border-radius-buttons-secondary); +} + +.quantity-selector :is(.quantity-minus, .quantity-plus) { + /* Unset button styles */ + padding: 0; + background: transparent; + box-shadow: none; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + width: var(--minimum-touch-target); + height: var(--minimum-touch-target); + flex-shrink: 0; + color: var(--color-input-text); +} + +.quantity-selector .quantity-minus { + border-start-start-radius: var(--style-border-radius-inputs); + border-end-start-radius: var(--style-border-radius-inputs); +} + +.quantity-selector .quantity-plus { + border-start-end-radius: var(--style-border-radius-inputs); + border-end-end-radius: var(--style-border-radius-inputs); +} + +.product-details .quantity-selector, +.quick-add-modal .quantity-selector { + border-radius: var(--style-border-radius-buttons-primary); +} + +.product-details .quantity-selector .quantity-minus, +.quick-add-modal .quantity-selector .quantity-minus { + border-start-start-radius: var(--style-border-radius-buttons-primary); + border-end-start-radius: var(--style-border-radius-buttons-primary); +} + +.product-details .quantity-selector .quantity-plus, +.quick-add-modal .quantity-selector .quantity-plus { + border-start-end-radius: var(--style-border-radius-buttons-primary); + border-end-end-radius: var(--style-border-radius-buttons-primary); +} + +.quantity-selector .svg-wrapper { + transition: transform var(--animation-speed) var(--animation-easing); +} + +.quantity-selector svg { + width: var(--icon-size-xs); + height: var(--icon-size-xs); +} + +:is(.quantity-minus, .quantity-plus):active .svg-wrapper { + transform: scale(0.9); +} + +.quantity-selector input[type='number'] { + margin: 0; + text-align: center; + border: none; + appearance: none; + max-width: calc(var(--quantity-selector-width) - var(--minimum-touch-target) * 2); + border-radius: var(--style-border-radius-buttons); + color: var(--color-input-text); + background-color: transparent; +} + +/* Chrome, Safari, Edge, Opera */ +.quantity-selector input[type='number']::-webkit-inner-spin-button, +.quantity-selector input[type='number']::-webkit-outer-spin-button { + appearance: none; +} + +/* Firefox */ +.quantity-selector input[type='number'] { + appearance: textfield; +} + +/* Pills (used in facets and predictive search) */ + +.pills__pill { + --pills-pill-background-color: rgb(var(--color-foreground-rgb) / var(--opacity-5-15)); + + color: var(--color-foreground); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--gap-sm); + min-width: 48px; + padding: 6px 12px; + border-radius: var(--style-border-radius-pills); + cursor: pointer; + background-color: var(--pills-pill-background-color); + transition: background-color var(--animation-speed) var(--animation-easing); + + &:hover { + --pills-pill-background-color: rgb(var(--color-foreground-rgb) / var(--opacity-10-25)); + } + + @media screen and (max-width: 749px) { + padding: var(--padding-xs) var(--padding-md); + } +} + +.pills__pill > .svg-wrapper { + --close-icon-opacity: 0.4; + --icon-stroke-width: 1px; + + color: var(--color-foreground); +} + +.pills__pill--swatch { + @media screen and (max-width: 749px) { + padding-inline-start: var(--padding-sm); + } +} + +.pills__pill--swatch .swatch { + margin-right: -4px; +} + +.pills__pill--desktop-small { + @media screen and (min-width: 750px) { + font-size: var(--font-size--xs); + } +} + +/* Fly to cart animation */ +fly-to-cart { + --offset-y: 10px; + + position: fixed; + width: var(--width, 40px); + height: var(--height, 40px); + left: 0; + top: 0; + z-index: calc(infinity); + pointer-events: none; + border-radius: var(--style-border-radius-buttons-primary); + overflow: hidden; + object-fit: cover; + background-size: cover; + background-position: center; + opacity: 0; + background-color: var(--color-foreground); + translate: var(--start-x, 0) var(--start-y, 0); + transform: translate(-50%, -50%); + animation-name: travel-x, travel-y, travel-scale; + animation-timing-function: var(--x-timing), var(--y-timing), var(--scale-timing); + animation-duration: 0.6s; + animation-composition: accumulate; + animation-fill-mode: both; +} + +fly-to-cart.fly-to-cart--main { + --x-timing: cubic-bezier(0.7, -5, 0.98, 0.5); + --y-timing: cubic-bezier(0.15, 0.57, 0.9, 1.05); + --scale-timing: cubic-bezier(0.85, 0.05, 0.96, 1); +} + +fly-to-cart.fly-to-cart--quick { + --x-timing: cubic-bezier(0, -0.1, 1, 0.32); + --y-timing: cubic-bezier(0, 0.92, 0.92, 1.04); + --scale-timing: cubic-bezier(0.86, 0.08, 0.98, 0.98); + + animation-duration: 0.6s; +} + +fly-to-cart.fly-to-cart--sticky { + --x-timing: cubic-bezier(0.98, -0.8, 0.92, 0.5); + --y-timing: cubic-bezier(0.14, 0.56, 0.92, 1.04); + --scale-timing: cubic-bezier(0.86, 0.08, 0.98, 0.98); + --radius: var(--style-border-radius-buttons-primary); + + @media screen and (max-width: 749px) { + --x-timing: cubic-bezier(0.98, -0.1, 0.92, 0.5); + } + + animation-duration: 0.8s; +} + +@keyframes travel-scale { + 0% { + opacity: var(--start-opacity, 1); + } + + 5% { + opacity: 1; + } + + 100% { + border-radius: 50%; + opacity: 1; + transform: translate(-50%, calc(-50% + var(--offset-y))) scale(0.25); + } +} + +@keyframes travel-x { + to { + translate: var(--travel-x, 0) 0; + } +} + +@keyframes travel-y { + to { + translate: 0 var(--travel-y, 0); + } +} + +/* ------------------------------------------------------------------------------ */ + +/* Collection Wrapper - Shared layout CSS for collection and search pages */ + +/* ------------------------------------------------------------------------------ */ + +.collection-wrapper { + @media screen and (min-width: 750px) { + --facets-vertical-col-width: 6; + + grid-template-columns: + 1fr repeat( + var(--centered-column-number), + minmax(0, calc((var(--page-width) - var(--page-margin) * 2) / var(--centered-column-number))) + ) + 1fr; + } + + @media screen and (min-width: 990px) { + --facets-vertical-col-width: 5; + } +} + +.collection-wrapper:has(.facets-block-wrapper--full-width), +.collection-wrapper:has(.collection-wrapper--full-width) { + @media screen and (min-width: 750px) { + grid-column: 1 / -1; + grid-template-columns: + minmax(var(--page-margin), 1fr) repeat( + var(--centered-column-number), + minmax(0, calc((var(--page-width) - var(--page-margin) * 2) / var(--centered-column-number))) + ) + minmax(var(--page-margin), 1fr); + } +} + +.collection-wrapper:has(.facets--vertical) .facets-block-wrapper--vertical:not(.hidden) ~ .main-collection-grid { + @media screen and (min-width: 750px) { + grid-column: var(--facets-vertical-col-width) / var(--full-width-column-number); + } +} + +.collection-wrapper:has(.facets-block-wrapper--vertical:not(#filters-drawer)):has(.collection-wrapper--full-width) { + @media screen and (min-width: 750px) { + grid-column: 1 / -1; + grid-template-columns: 0fr repeat(var(--centered-column-number), minmax(0, 1fr)) 0fr; + } +} + +:is(.collection-wrapper--full-width, .collection-wrapper--full-width-on-mobile) + [product-grid-view='default'] + .product-grid__card { + @media screen and (max-width: 749px) { + padding-inline-start: max(var(--padding-xs), var(--padding-inline-start)); + padding-inline-end: max(var(--padding-xs), var(--padding-inline-end)); + } +} + +:is(.collection-wrapper--full-width, .collection-wrapper--full-width-on-mobile) + [product-grid-view='mobile-single'] + .product-grid__card { + @media screen and (max-width: 749px) { + padding-inline-start: max(var(--padding-xs), var(--padding-inline-start)); + padding-inline-end: max(var(--padding-xs), var(--padding-inline-end)); + } +} + +/* Make product media go edge-to-edge by using negative margins */ +:is(.collection-wrapper--full-width) .card-gallery, +:is(.collection-wrapper--full-width-on-mobile) .card-gallery { + @media screen and (max-width: 749px) { + margin-inline-start: calc(-1 * max(var(--padding-xs), var(--padding-inline-start))); + margin-inline-end: calc(-1 * max(var(--padding-xs), var(--padding-inline-end))); + } +} + +.collection-wrapper--full-width .main-collection-grid__title { + margin-left: var(--page-margin); +} + +.collection-wrapper--full-width-on-mobile .main-collection-grid__title { + @media screen and (max-width: 749px) { + margin-left: var(--page-margin); + } +} + +.collection-wrapper--grid-full-width .facets--vertical:not(.facets--drawer) { + @media screen and (min-width: 750px) { + padding-inline-start: max(var(--padding-sm), var(--padding-inline-start)); + } +} + +.collection-wrapper:has(.product-grid-mobile--large) .facets-mobile-wrapper.facets-controls-wrapper { + @media screen and (max-width: 749px) { + display: none; + } +} + +.collection-wrapper:has(> .facets--horizontal) .facets__panel[open] { + @media screen and (min-width: 750px) { + z-index: var(--facets-open-z-index); + } +} + +/* ------------------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------------------ */ + +/* Animation declarations - to be kept at the bottom of the file for ease of find */ +@keyframes grow { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.2); + } + + 100% { + transform: scale(1); + } +} + +@keyframes move-and-fade { + from { + transform: translate(var(--start-x, 0), var(--start-y, 0)); + opacity: var(--start-opacity, 0); + } + + to { + transform: translate(var(--end-x, 0), var(--end-y, 0)); + opacity: var(--end-opacity, 1); + } +} + +@keyframes slideInTopViewTransition { + from { + transform: translateY(100px); + } +} + +@keyframes elementSlideInTop { + from { + margin-top: var(--padding-sm); + opacity: 0; + } + + to { + margin-top: 0; + opacity: 1; + } +} + +@keyframes elementSlideOutTop { + from { + transform: translateY(0); + opacity: 1; + } + + to { + transform: translateY(var(--padding-sm)); + opacity: 0; + } +} + +@keyframes elementSlideInBottom { + from { + transform: translateY(calc(-1 * var(--padding-sm))); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes elementSlideOutBottom { + from { + transform: translateY(0); + opacity: 1; + } + + to { + transform: translateY(calc(-1 * var(--padding-sm))); + opacity: 0; + } +} + +@keyframes thumbnailsSlideInTop { + from { + transform: translateY(calc(-50% + var(--margin-lg))); + opacity: 0; + } + + to { + transform: translateY(-50%); + opacity: 1; + } +} + +@keyframes thumbnailsSlideOutTop { + from { + transform: translateY(-50%); + opacity: 1; + } + + to { + transform: translateY(calc(-50% + var(--margin-lg))); + opacity: 0; + } +} + +@keyframes thumbnailsSlideInBottom { + from { + transform: translateY(100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes thumbnailsSlideOutBottom { + from { + transform: translateY(0); + opacity: 1; + } + + to { + transform: translateY(100%); + opacity: 0; + } +} + +@keyframes search-element-slide-in-bottom { + 0% { + transform: translateY(20px); + opacity: 0; + } + + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes search-element-slide-out-bottom { + 0% { + transform: translateY(0); + opacity: 1; + } + + 100% { + transform: translateY(20px); + opacity: 0; + } +} + +@keyframes dialogZoom { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + + to { + opacity: 0; + transform: scale(0.95) translateY(1em); + } +} + +@keyframes thumbnail-selected { + 0%, + 100% { + box-shadow: 0 0 0 2px transparent; + scale: 0.9; + } + + 50% { + box-shadow: 0 0 0 2px #000; + scale: 1; + } +} + +@keyframes backdropFilter { + from { + backdrop-filter: brightness(1); + } + + to { + backdrop-filter: brightness(0.75); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes modalSlideInTop { + from { + transform: translateY(var(--padding-sm)); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes modalSlideOutTop { + from { + transform: translateY(0); + opacity: 1; + } + + to { + transform: translateY(var(--padding-sm)); + opacity: 0; + } +} + +.bubble { + display: inline-flex; + height: calc(var(--variant-picker-swatch-height) / 1.5); + font-size: var(--font-size--xs); + border-radius: 20px; + min-width: 20px; + padding: 0 6px; + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-10-25)); + color: var(--color-foreground); + align-items: center; + justify-content: center; +} + +.bubble svg { + width: 12px; + height: 12px; +} + +.top-shadow::before { + content: ''; + box-shadow: 0 0 10px var(--color-shadow); + position: absolute; + z-index: var(--layer-lowest); + inset: 0; + clip-path: inset(-50px 0 0 0); /* stylelint-disable-line */ +} + +@media screen and (min-width: 750px) { + .top-shadow--mobile::before { + display: none; + } +} + +.bottom-shadow::before { + content: ''; + box-shadow: 0 0 10px var(--color-shadow); + position: absolute; + z-index: var(--layer-lowest); + inset: 0; + clip-path: inset(0 0 -50px 0); /* stylelint-disable-line */ +} + +@media screen and (min-width: 750px) { + .bottom-shadow--mobile::before { + display: none; + } +} + +.video-placeholder-wrapper { + position: relative; + width: 100%; + height: 100%; + aspect-ratio: var(--size-style-aspect-ratio, auto); +} + +:not(deferred-media) > .video-placeholder-wrapper { + width: var(--video-placeholder-width); +} + +.video-placeholder-wrapper > * { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* + * Slideshow Component + */ +slideshow-component { + --cursor: grab; + --slide-offset: 6px; + + position: relative; + display: flex; + flex-direction: column; + timeline-scope: var(--slideshow-timeline); +} + +slideshow-component.slideshow--content-below-media slideshow-slide { + display: grid; +} + +.slideshow--content-below-media slideshow-slide :is(.slide__image-container, .slide__content) { + position: static; +} + +.slideshow--content-below-media slideshow-slide { + grid-template-rows: var(--grid-template-rows); + + @media screen and (min-width: 750px) { + grid-template-rows: var(--grid-template-rows-desktop); + } +} + +.slide__content { + @supports (animation-timeline: auto) { + opacity: 0; + animation: slide-reveal both linear; + animation-timeline: var(--slideshow-timeline); + } + + @media (prefers-reduced-motion) { + opacity: 1; + animation: none; + } +} + +/* + * Force Safari to recalculate the timeline state on timeline refresh (after loop) +*/ +slideshow-component[refreshing-timeline] .slide__content { + animation: none; +} + +.slideshow--single-media { + --cursor: default; +} + +a slideshow-component { + --cursor: pointer; +} + +/* + * Slideshow Slides + */ +slideshow-slides { + width: 100%; + position: relative; + display: flex; + overflow-x: scroll; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + scrollbar-color: transparent transparent; + scrollbar-width: none; + gap: var(--slideshow-gap, 0); + cursor: var(--cursor); + min-height: var(--slide-min-height); + + @media (prefers-reduced-motion) { + scroll-behavior: auto; + } + + &::-webkit-scrollbar { + width: 0; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: transparent; + border: none; + } + + @media screen and (min-width: 750px) { + min-height: var(--slide-min-height-desktop); + } +} + +slideshow-component[disabled='true'] slideshow-slides { + overflow: hidden; +} + +/** + * By default, slideshows have overflow: hidden (no compositor layer). + * When the slideshow enters the viewport, JavaScript adds [in-viewport] which enables scrolling. + */ +slideshow-component:not([in-viewport]) slideshow-slides { + overflow: hidden; +} + +slideshow-component[mobile-disabled] slideshow-slides { + @media screen and (max-width: 749px) { + overflow: hidden; + } +} + +slideshow-slide { + position: relative; + scroll-snap-align: center; + width: var(--slide-width, 100%); + max-height: 100%; + flex-shrink: 0; + view-timeline-name: var(--slideshow-timeline); + view-timeline-axis: inline; + content-visibility: auto; + contain-intrinsic-size: auto none; + border-radius: var(--corner-radius, 0); + overflow: hidden; + + slideshow-component[actioned] &, + &[aria-hidden='false'] { + content-visibility: visible; + } + + slideshow-component slideshow-slide:not([aria-hidden='false']) { + content-visibility: hidden; + } + + &[hidden]:not([reveal]) { + display: none; + } + + /* Make inactive slides appear clickable */ + &[aria-hidden='true'] { + cursor: pointer; + } +} + +slideshow-slide .slide__image-container--rounded { + border-radius: var(--corner-radius, 0); +} + +slideshow-slide.product-media-container--tallest { + content-visibility: visible; +} + +@media screen and (max-width: 749px) { + /* Media gallery has a peeking slide on the right side always, and on the left side when the current slide is the last one */ + .media-gallery--hint + :is( + slideshow-slide:has(+ slideshow-slide[aria-hidden='false']:last-of-type), + slideshow-slide[aria-hidden='false'] + slideshow-slide + ) { + content-visibility: auto; + + slideshow-component[actioned] & { + content-visibility: visible; + } + } +} + +/* + * Collection and Resource list carousels have peeking slides on both sides. + * Card galleries preview the next or previous images on 'pointerenter', so we + * try to kick load them beforehand (they are lazy loaded otherwise). + */ +:is(.resource-list__carousel, .card-gallery) + :is( + slideshow-slide:has(+ slideshow-slide[aria-hidden='false']), + slideshow-slide[aria-hidden='false'] + slideshow-slide + ) { + content-visibility: auto; + + slideshow-component[actioned] & { + content-visibility: visible; + } +} + +/* + * Be specific about HTML children structure to avoid targeting nested slideshows. + * Ensure that the content is 'visible' while scrolling instead of 'auto' to avoid issues in Safari. + */ +slideshow-component:is([dragging], [transitioning], :hover) > slideshow-container > slideshow-slides > slideshow-slide { + content-visibility: visible; +} + +slideshow-slides[gutters*='start'] { + padding-inline-start: var(--gutter-slide-width, 0); + scroll-padding-inline-start: var(--gutter-slide-width, 0); +} + +slideshow-slides[gutters*='end'] { + padding-inline-end: var(--gutter-slide-width, 0); +} + +slideshow-component[dragging] { + --cursor: grabbing; + + * { + pointer-events: none; + } +} + +slideshow-component[dragging] slideshow-arrows { + display: none; +} + +slideshow-container { + width: 100%; + display: block; + position: relative; + grid-area: container; + container-type: inline-size; + background-color: var(--color-background); +} + +@media screen and (min-width: 750px) { + .media-gallery--carousel slideshow-component:has(slideshow-controls[thumbnails]) { + &:has(slideshow-controls[pagination-position='right']) { + display: grid; + grid-template: + 'container controls' auto + 'arrows controls' min-content + / 1fr auto; + } + + &:has(slideshow-controls[pagination-position='left']) { + display: grid; + grid-template: + 'controls container' auto + 'controls arrows' min-content + / auto 1fr; + } + + slideshow-controls[pagination-position='left'] { + order: -1; + } + } +} + +/* Slideshow Play/Pause */ +.slideshow-control:is(.icon-pause, .icon-play) { + color: var(--color-active); + + &:hover { + color: var(--color-hover); + } + + svg { + display: none; + } +} + +slideshow-component:is([autoplay]) { + &:is([paused]) { + .icon-play > svg { + display: block; + } + } + + &:not([paused]) { + .icon-pause > svg { + display: block; + } + } +} + +/* Slideshow Arrows */ +slideshow-arrows { + --cursor-previous: w-resize; + --cursor-next: e-resize; + + position: absolute; + inset: 0; + display: flex; + z-index: var(--layer-heightened); + pointer-events: none; + mix-blend-mode: difference; + align-items: flex-end; + + &[position='left'] { + justify-content: flex-start; + padding-inline: var(--padding-xs); + } + + &[position='right'] { + justify-content: flex-end; + padding-inline: var(--padding-xs); + } + + &[position='center'] { + justify-content: space-between; + align-items: center; + } +} + +slideshow-arrows:has(.slideshow-control--shape-square), +slideshow-arrows:has(.slideshow-control--shape-circle) { + mix-blend-mode: normal; +} + +slideshow-component[disabled='true'] slideshow-arrows { + display: none; +} + +slideshow-arrows .slideshow-control { + pointer-events: auto; + opacity: 0; + min-height: var(--minimum-touch-target); + min-width: var(--minimum-touch-target); + padding: 0 var(--padding-xs); + color: var(--color-white); +} + +slideshow-arrows .slideshow-control.slideshow-control--style-none { + display: none; +} + +.media-gallery--carousel slideshow-arrows .slideshow-control { + padding-inline: 0 var(--padding-md); + opacity: 1; +} + +.card-gallery slideshow-arrows .slideshow-control { + /* Align icons with quick-add button */ + padding-inline: var(--padding-xl); + + @container (max-width: 249px) { + padding-inline: 0 var(--padding-sm); + } +} + +:not(.media-gallery--carousel) + > :is(slideshow-component:hover, slideshow-component:focus-within):not(:has(slideshow-controls:hover)) + > slideshow-container + > slideshow-arrows + .slideshow-control { + animation: arrowsSlideIn var(--animation-speed) var(--animation-easing) forwards; +} + +@keyframes arrowsSlideIn { + from { + transform: translate(var(--padding-sm), 0); + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slide-reveal { + 0% { + translate: calc(var(--slideshow-slide-offset, 6) * 1rem) 0; + opacity: 0; + } + + 50% { + opacity: 1; + } + + 100% { + translate: calc(var(--slideshow-slide-offset, 6) * -1rem) 0; + opacity: 0; + } +} + +.section-resource-list, +.section-carousel { + row-gap: var(--gap); +} + +.section-resource-list__content { + display: flex; + flex-direction: column; + align-items: var(--horizontal-alignment); + gap: var(--gap); + width: 100%; +} + +.section-resource-list__content:empty { + display: none; +} + +.section-resource-list__header:is(:empty, :has(.group-block-content:empty)), +.section-resource-list__content:empty { + display: none; +} + +.section-resource-list.section--full-width product-card-link > .group-block, +.section-carousel.section--full-width product-card-link > .group-block { + @media screen and (max-width: 749px) { + padding-inline: max(var(--padding-xs), var(--padding-inline-start)) + max(var(--padding-xs), var(--padding-inline-end)); + } +} + +.resource-list--carousel-mobile { + display: block; + + @media screen and (min-width: 750px) { + display: none; + } +} + +.resource-list { + --resource-list-mobile-gap-max: 9999px; + --resource-list-column-gap: min(var(--resource-list-column-gap-desktop), var(--resource-list-mobile-gap-max)); + --resource-list-row-gap: min(var(--resource-list-row-gap-desktop), var(--resource-list-mobile-gap-max)); + + width: 100%; + + @media screen and (max-width: 749px) { + --resource-list-mobile-gap-max: 12px; + } + + @container resource-list (max-width: 749px) { + --resource-list-mobile-gap-max: 12px; + } +} + +.resource-list--grid { + display: grid; + gap: var(--resource-list-row-gap) var(--resource-list-column-gap); + grid-template-columns: var(--resource-list-columns-mobile); + + @media screen and (min-width: 750px) { + grid-template-columns: var(--resource-list-columns); + } + + @container resource-list (max-width: 449px) { + grid-template-columns: var(--resource-list-columns-mobile); + } + + @container resource-list(min-width: 450px) and (max-width: 749px) { + --resource-list-columns-per-row: 3; + + grid-template-columns: repeat(var(--resource-list-columns-per-row), 1fr); + + /* Avoid orphan in last row when there are 4, 7, or 10 items */ + &:has(.resource-list__item:first-child:nth-last-child(3n + 1)), + /* Clean two full rows when there are 8 items */ + &:has(.resource-list__item:first-child:nth-last-child(8n)) { + --resource-list-columns-per-row: 4; + } + } + + @container resource-list (min-width: 750px) { + grid-template-columns: repeat(var(--resource-list-columns-per-row), 1fr); + + &:has(.resource-list__item:first-child:nth-last-child(n + 9)) { + --resource-list-columns-per-row: 5; + } + + &:has(.resource-list__item:first-child:nth-last-child(n + 7):nth-last-child(-n + 8)) { + --resource-list-columns-per-row: 4; + } + + &:has(.resource-list__item:first-child:nth-last-child(6)) { + --resource-list-columns-per-row: 3; + } + + &:has(.resource-list__item:first-child:nth-last-child(5)) { + --resource-list-columns-per-row: 5; + } + + &:has(.resource-list__item:first-child:nth-last-child(-n + 4)) { + --resource-list-columns-per-row: 4; + } + } + + @container resource-list (min-width: 1200px) { + &:has(.resource-list__item:first-child:nth-last-child(6)) { + --resource-list-columns-per-row: 6; + } + } +} + +.resource-list__item { + height: 100%; + color: var(--color-foreground); + text-decoration: none; +} + +.resource-list__carousel { + --slide-width: 60vw; + + width: 100%; + position: relative; + container-type: inline-size; + container-name: resource-list-carousel; + + .slideshow-control[disabled] { + display: none; + } + + .slideshow-control--next { + margin-inline-start: auto; + } +} + +@container resource-list-carousel (max-width: 749px) { + .resource-list__carousel .resource-list__slide { + --slide-width: clamp(150px, var(--mobile-card-size, 60cqw), var(--slide-width-max)); + } +} + +@container resource-list-carousel (min-width: 750px) { + .resource-list__carousel .resource-list__slide { + --section-slide-width: calc( + (100% - (var(--resource-list-column-gap) * (var(--column-count) - 1)) - var(--peek-next-slide-size)) / + var(--column-count) + ); + --fallback-slide-width: clamp(150px, var(--mobile-card-size, 60cqw), var(--slide-width-max)); + --slide-width: var(--section-slide-width, var(--fallback-slide-width)); + } +} + +.resource-list__carousel slideshow-slides { + gap: var(--resource-list-column-gap); + + /* Add padding to prevent hover animations from being clipped in slideshow + 15px accommodates: + - Scale effect (9px on each side from 1.03 scale) + - Lift effect (4px upward movement) + - Shadow (15px spread with -5px offset) + Using 16px for better alignment with our spacing scale */ + + margin-block: -16px; + padding-block: 16px; +} + +.resource-list__carousel slideshow-arrows { + padding-inline: var(--util-page-margin-offset); +} + +.resource-list__carousel .resource-list__slide { + width: var(--slide-width); + flex: 0 0 auto; + scroll-snap-align: start; + min-width: 0; +} + +/* Base styles */ +.group-block, +.group-block-content { + position: relative; +} + +.group-block:has(> video-background-component), +.group-block:has(> .background-image-container) { + overflow: hidden; +} + +.group-block-content { + height: 100%; + width: 100%; +} + +/* Container styles */ +.section-content-wrapper.section-content-wrapper:where(.layout-panel-flex) .group-block--fill { + flex: 1; +} + +/* Flex behavior for width variants */ +.layout-panel-flex--row > .group-block--width-fit { + flex: 0; +} + +.layout-panel-flex--row > .group-block--width-fill { + flex: 1; +} + +.layout-panel-flex--row > .group-block--width-custom { + flex-basis: var(--size-style-width); +} + +/* Dimension utilities - Height */ +.group-block--height-fit { + height: auto; +} + +.group-block--height-custom, +.group-block--height-fill { + height: var(--size-style-height); +} + +/* Flex behavior for height variants */ +.layout-panel-flex--column > .group-block--height-fit { + flex: 0 1 auto; +} + +.layout-panel-flex--column > .group-block--height-fill { + flex: 1; +} + +.layout-panel-flex--column > .group-block--height-custom { + flex-basis: var(--size-style-height); +} + +accordion-custom { + details { + &::details-content, + .details-content { + block-size: 0; + overflow-y: clip; + opacity: 0; + interpolate-size: allow-keywords; + transition: content-visibility var(--animation-speed-slow) allow-discrete, + padding-block var(--animation-speed-slow) var(--animation-easing), + opacity var(--animation-speed-slow) var(--animation-easing), + block-size var(--animation-speed-slow) var(--animation-easing); + } + + /* Disable transitions when the content toggle is not caused by the direct user interaction, e.g. opening the filters on mobile. */ + &:not(:focus-within)::details-content, + &:not(:focus-within) .details-content { + transition: none; + } + + &:not([open]) { + &::details-content, + .details-content { + padding-block: 0; + } + } + + &[open] { + &::details-content, + .details-content { + opacity: 1; + block-size: auto; + + @starting-style { + block-size: 0; + opacity: 0; + overflow-y: clip; + } + + &:focus-within { + overflow-y: visible; + } + } + } + } +} + +accordion-custom[data-disable-on-mobile='true'] summary { + @media screen and (max-width: 749px) { + cursor: auto; + } +} + +accordion-custom[data-disable-on-desktop='true'] summary { + @media screen and (min-width: 750px) { + cursor: auto; + } +} + +text-component { + --shimmer-text-color: rgb(var(--color-foreground-rgb) / var(--opacity-50)); + --shimmer-color-light: rgb(var(--color-foreground-rgb) / var(--opacity-10)); + --shimmer-speed: 1.25s; + + display: inline-block; + position: relative; + transition: color var(--animation-speed-slow) ease; + line-height: 1; + + &::after { + content: attr(value); + position: absolute; + inset: 0; + color: transparent; + opacity: 0; + transition: opacity var(--animation-speed-slow) var(--animation-easing); + pointer-events: none; + background-image: linear-gradient( + -85deg, + var(--shimmer-text-color) 10%, + var(--shimmer-color-light) 50%, + var(--shimmer-text-color) 90% + ); + background-clip: text; + background-size: 200% 100%; + background-position: 100% 0; + place-content: center; + } + + &[shimmer] { + color: transparent; + + &::after { + opacity: 1; + animation: text-shimmer var(--shimmer-speed) infinite linear; + } + } +} + +@keyframes text-shimmer { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: -100% 0; + } +} + +/* Animation transitions */ +.transition-background-color { + transition: background-color var(--animation-speed-medium) ease-in-out; +} + +.transition-transform { + transition: transform var(--animation-speed-medium) var(--animation-timing-bounce); +} + +.transition-border-color { + transition: border-color var(--animation-speed-medium) var(--animation-timing-hover); +} + +/* Global scrollbar styles */ + +/* Webkit browsers */ +::-webkit-scrollbar { + width: 20px; +} + +::-webkit-scrollbar-track { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-40)); + border-radius: 20px; + border: 6px solid transparent; + background-clip: content-box; + transition: background-color 0.2s; +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgb(var(--color-foreground-rgb) / var(--opacity-60)); +} + +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +} + +/* Product card title truncation - applied only to zoom-out view */ +[product-grid-view='zoom-out'] :is(.product-card, .product-grid__card) :is(h4, .h4) { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 3; +} + +/* Product card title truncation - applied on mobile regardless of view */ +@media screen and (max-width: 749px) { + :is(.product-card, .product-grid__card) :is(h4, .h4) { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 3; + } +} + +.product-card:hover, +.collection-card:hover, +.resource-card:hover, +.predictive-search-results__card--product:hover, +.predictive-search-results__card:hover { + position: relative; + z-index: var(--layer-raised); + transition: transform var(--hover-transition-duration) var(--hover-transition-timing), + box-shadow var(--hover-transition-duration) var(--hover-transition-timing); +} + +.header .product-card:hover, +.header .collection-card:hover, +.header .resource-card:hover, +.header-drawer .product-card:hover, +.header-drawer .collection-card:hover, +.header-drawer .resource-card:hover { + z-index: auto; + transform: none; + box-shadow: none; +} + +.predictive-search-results__inner { + flex-grow: 1; + overflow-y: auto; + padding-block: var(--padding-lg); + container-type: inline-size; + color: var(--color-foreground); +} + +/* Prevent iOS zoom on input focus by ensuring minimum 16px font size on mobile */ +@media screen and (max-width: 1200px) { + input, + textarea, + select, + /* Higher specificity to override type preset classes like .paragraph, .h1, etc. */ + .paragraph.paragraph input, + .paragraph.paragraph textarea, + .paragraph.paragraph select, + .h1.h1 input, + .h1.h1 textarea, + .h1.h1 select, + .h2.h2 input, + .h2.h2 textarea, + .h2.h2 select, + .h3.h3 input, + .h3.h3 textarea, + .h3.h3 select, + .h4.h4 input, + .h4.h4 textarea, + .h4.h4 select, + .h5.h5 input, + .h5.h5 textarea, + .h5.h5 select, + .h6.h6 input, + .h6.h6 textarea, + .h6.h6 select { + font-size: max(1rem, 100%); + } +} + +.product-recommendations { + display: block; +} + +.product-recommendations__skeleton-item { + aspect-ratio: 3 / 4; + background-color: var(--color-foreground); + opacity: var(--skeleton-opacity); + border-radius: 4px; +} + +@media screen and (max-width: 749px) { + .product-recommendations__skeleton-item:nth-child(2n + 1) { + display: none; + } +} + +product-recommendations:has([data-has-recommendations='false']) { + display: none; +} + +.add-to-cart-button { + --text-speed: 0.26; + --base-delay: calc(var(--text-speed) * 0.25); + --tick-speed: 0.1; + --ring-speed: 0.2; + --check-speed: 0.2; + --burst-speed: 0.32; + --step-delay: 3; + --speed: 1; + + user-select: none; + transition-property: color, box-shadow, background-color, scale, translate; + transition-duration: var(--animation-speed); + transition-timing-function: var(--ease-out-cubic); + + &:active { + scale: 0.99; + translate: 0 1px; + } +} + +.add-to-cart-button .svg-wrapper .checkmark-burst { + width: 30px; + height: 30px; +} + +.add-to-cart-text { + --atc-opacity: 0; + --atc-destination: -1em; + + display: flex; + gap: var(--gap-2xs); + align-items: center; + justify-content: center; + animation-duration: var(--animation-speed); + animation-timing-function: var(--animation-easing); + animation-fill-mode: forwards; + transition: width var(--animation-speed) var(--animation-easing), + opacity var(--animation-speed) var(--animation-easing); +} + +.add-to-cart__added { + --atc-opacity: 1; + --atc-destination: 0px; + + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; +} + +.add-to-cart__added-icon { + width: 32px; + height: 32px; +} + +[data-added='true'] .add-to-cart-text, +[data-added='true'] .add-to-cart__added { + animation-name: atc-slide; +} + +.checkmark-burst { + opacity: 0; + overflow: visible; + + .burst { + rotate: 20deg; + } + + .check { + opacity: 0.2; + scale: 0.8; + filter: blur(2px); + transform: translateZ(0); + } + + :is(.ring, .line, .check, .burst, .tick) { + transform-box: fill-box; + transform-origin: center; + } + + :is(.line) { + stroke-dasharray: 1.5 1.5; + stroke-dashoffset: -1.5; + translate: 0 -180%; + } + + g { + transform-origin: center; + rotate: calc(var(--index) * (360 / 8) * 1deg); + } +} + +.add-to-cart-button[data-added='true'] .checkmark-burst { + opacity: 1; +} + +.add-to-cart-button[data-added='true'] { + .check { + opacity: 1; + scale: 1; + filter: blur(0); + } + + .tick { + scale: 1.75; + } + + .ring { + opacity: 0; + scale: 1; + } + + .line { + stroke-dashoffset: 1.5; + } + + .add-to-cart-text { + /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ + clip-path: circle(0% at 50% 50%); + filter: blur(2px); + opacity: 0; + translate: 0 4px; + } +} + +@media (prefers-reduced-motion: no-preference) { + .add-to-cart-button[data-added='true'] { + .check { + transition-property: opacity, scale, filter; + transition-duration: calc(calc(var(--check-speed) * 1s)); + transition-delay: calc((var(--base-delay) * 1s)); + transition-timing-function: var(--ease-out-quad); + } + + .tick { + transition-property: scale; + transition-duration: calc((calc(var(--tick-speed) * 1s))); + transition-delay: calc(((var(--base-delay) + (var(--check-speed) * (var(--step-delay) * 1.1))) * 1s)); + transition-timing-function: ease-out; + } + + .ring { + transition-property: opacity, scale; + transition-duration: calc((calc(var(--ring-speed) * 1s))); + transition-delay: calc(((var(--base-delay) + (var(--check-speed) * var(--step-delay))) * 1s)); + transition-timing-function: var(--ease-out-quad); + } + + .line { + transition-property: stroke-dashoffset; + transition-duration: calc((calc(var(--burst-speed) * 1s))); + transition-delay: calc(((var(--base-delay) + (var(--check-speed) * var(--step-delay))) * 1s)); + transition-timing-function: var(--ease-out-cubic); + } + } + + .add-to-cart-text { + transition-property: clip-path, opacity, filter, translate; + transition-duration: calc((var(--text-speed) * 0.6s)), calc((var(--text-speed) * 1s)); + transition-timing-function: ease-out; + } +} + +.add-to-cart-text { + /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ + clip-path: circle(100% at 50% 50%); +} + +@keyframes atc-slide { + to { + opacity: var(--atc-opacity, 1); + translate: 0px var(--atc-destination, 0px); + } +} diff --git a/assets/blog-posts-list.js b/assets/blog-posts-list.js new file mode 100644 index 000000000..0ab0f879e --- /dev/null +++ b/assets/blog-posts-list.js @@ -0,0 +1,10 @@ +import PaginatedList from '@theme/paginated-list'; + +/** + * A custom element that renders a paginated blog posts list + */ +export default class BlogPostsList extends PaginatedList {} + +if (!customElements.get('blog-posts-list')) { + customElements.define('blog-posts-list', BlogPostsList); +} diff --git a/assets/cart-discount.js b/assets/cart-discount.js new file mode 100644 index 000000000..973badd8c --- /dev/null +++ b/assets/cart-discount.js @@ -0,0 +1,203 @@ +import { Component } from '@theme/component'; +import { morphSection } from '@theme/section-renderer'; +import { DiscountUpdateEvent } from '@theme/events'; +import { fetchConfig } from '@theme/utilities'; +import { cartPerformance } from '@theme/performance'; + +/** + * A custom element that applies a discount to the cart. + * + * @typedef {Object} CartDiscountComponentRefs + * @property {HTMLElement} cartDiscountError - The error element. + * @property {HTMLElement} cartDiscountErrorDiscountCode - The discount code error element. + * @property {HTMLElement} cartDiscountErrorShipping - The shipping error element. + */ + +/** + * @extends {Component} + */ +class CartDiscount extends Component { + requiredRefs = ['cartDiscountError', 'cartDiscountErrorDiscountCode', 'cartDiscountErrorShipping']; + + /** @type {AbortController | null} */ + #activeFetch = null; + + #createAbortController() { + if (this.#activeFetch) { + this.#activeFetch.abort(); + } + + const abortController = new AbortController(); + this.#activeFetch = abortController; + return abortController; + } + + /** + * Handles updates to the cart note. + * @param {SubmitEvent} event - The submit event on our form. + */ + applyDiscount = async (event) => { + const { cartDiscountError, cartDiscountErrorDiscountCode, cartDiscountErrorShipping } = this.refs; + + event.preventDefault(); + event.stopPropagation(); + + const form = event.target; + if (!(form instanceof HTMLFormElement)) return; + + const discountCode = form.querySelector('input[name="discount"]'); + if (!(discountCode instanceof HTMLInputElement) || typeof this.dataset.sectionId !== 'string') return; + + const discountCodeValue = discountCode.value; + + const abortController = this.#createAbortController(); + + try { + const existingDiscounts = this.#existingDiscounts(); + if (existingDiscounts.includes(discountCodeValue)) return; + + cartDiscountError.classList.add('hidden'); + cartDiscountErrorDiscountCode.classList.add('hidden'); + cartDiscountErrorShipping.classList.add('hidden'); + + const config = fetchConfig('json', { + body: JSON.stringify({ + discount: [...existingDiscounts, discountCodeValue].join(','), + sections: [this.dataset.sectionId], + }), + }); + + const response = await fetch(Theme.routes.cart_update_url, { + ...config, + signal: abortController.signal, + }); + + const data = await response.json(); + + if ( + data.discount_codes.find((/** @type {{ code: string; applicable: boolean; }} */ discount) => { + return discount.code === discountCodeValue && discount.applicable === false; + }) + ) { + discountCode.value = ''; + this.#handleDiscountError('discount_code'); + return; + } + + const newHtml = data.sections[this.dataset.sectionId]; + const parsedHtml = new DOMParser().parseFromString(newHtml, 'text/html'); + const section = parsedHtml.getElementById(`shopify-section-${this.dataset.sectionId}`); + const discountCodes = section?.querySelectorAll('.cart-discount__pill') || []; + if (section) { + const codes = Array.from(discountCodes) + .map((element) => (element instanceof HTMLLIElement ? element.dataset.discountCode : null)) + .filter(Boolean); + // Before morphing, we need to check if the shipping discount is applicable in the UI + // we check the liquid logic compared to the cart payload to assess whether we leveraged + // a valid shipping discount code. + if ( + codes.length === existingDiscounts.length && + codes.every((/** @type {string} */ code) => existingDiscounts.includes(code)) && + data.discount_codes.find((/** @type {{ code: string; applicable: boolean; }} */ discount) => { + return discount.code === discountCodeValue && discount.applicable === true; + }) + ) { + this.#handleDiscountError('shipping'); + discountCode.value = ''; + return; + } + } + + document.dispatchEvent(new DiscountUpdateEvent(data, this.id)); + morphSection(this.dataset.sectionId, newHtml); + } catch (error) { + } finally { + this.#activeFetch = null; + cartPerformance.measureFromEvent('discount-update:user-action', event); + } + }; + + /** + * Handles removing a discount from the cart. + * @param {MouseEvent | KeyboardEvent} event - The mouse or keyboard event in our pill. + */ + removeDiscount = async (event) => { + event.preventDefault(); + event.stopPropagation(); + + if ( + (event instanceof KeyboardEvent && event.key !== 'Enter') || + !(event instanceof MouseEvent) || + !(event.target instanceof HTMLElement) || + typeof this.dataset.sectionId !== 'string' + ) { + return; + } + + const pill = event.target.closest('.cart-discount__pill'); + if (!(pill instanceof HTMLLIElement)) return; + + const discountCode = pill.dataset.discountCode; + if (!discountCode) return; + + const existingDiscounts = this.#existingDiscounts(); + const index = existingDiscounts.indexOf(discountCode); + if (index === -1) return; + + existingDiscounts.splice(index, 1); + + const abortController = this.#createAbortController(); + + try { + const config = fetchConfig('json', { + body: JSON.stringify({ discount: existingDiscounts.join(','), sections: [this.dataset.sectionId] }), + }); + + const response = await fetch(Theme.routes.cart_update_url, { + ...config, + signal: abortController.signal, + }); + + const data = await response.json(); + + document.dispatchEvent(new DiscountUpdateEvent(data, this.id)); + morphSection(this.dataset.sectionId, data.sections[this.dataset.sectionId]); + } catch (error) { + } finally { + this.#activeFetch = null; + } + }; + + /** + * Handles the discount error. + * + * @param {'discount_code' | 'shipping'} type - The type of discount error. + */ + #handleDiscountError(type) { + const { cartDiscountError, cartDiscountErrorDiscountCode, cartDiscountErrorShipping } = this.refs; + const target = type === 'discount_code' ? cartDiscountErrorDiscountCode : cartDiscountErrorShipping; + cartDiscountError.classList.remove('hidden'); + target.classList.remove('hidden'); + } + + /** + * Returns an array of existing discount codes. + * @returns {string[]} + */ + #existingDiscounts() { + /** @type {string[]} */ + const discountCodes = []; + const discountPills = this.querySelectorAll('.cart-discount__pill'); + for (const pill of discountPills) { + if (pill instanceof HTMLLIElement && typeof pill.dataset.discountCode === 'string') { + discountCodes.push(pill.dataset.discountCode); + } + } + + return discountCodes; + } +} + +if (!customElements.get('cart-discount-component')) { + customElements.define('cart-discount-component', CartDiscount); +} diff --git a/assets/cart-drawer.js b/assets/cart-drawer.js new file mode 100644 index 000000000..31715f3e3 --- /dev/null +++ b/assets/cart-drawer.js @@ -0,0 +1,74 @@ +import { DialogComponent, DialogOpenEvent } from '@theme/dialog'; +import { CartAddEvent } from '@theme/events'; + +/** + * A custom element that manages a cart drawer. + * + * @typedef {object} Refs + * @property {HTMLDialogElement} dialog - The dialog element. + * + * @extends {DialogComponent} + */ +class CartDrawerComponent extends DialogComponent { + /** @type {number} */ + #summaryThreshold = 0.5; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener(CartAddEvent.eventName, this.#handleCartAdd); + this.addEventListener(DialogOpenEvent.eventName, this.#updateStickyState); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener(CartAddEvent.eventName, this.#handleCartAdd); + this.removeEventListener(DialogOpenEvent.eventName, this.#updateStickyState); + } + + #handleCartAdd = () => { + if (this.hasAttribute('auto-open')) { + this.showDialog(); + } + }; + + open() { + this.showDialog(); + + /** + * Close cart drawer when installments CTA is clicked to avoid overlapping dialogs + */ + customElements.whenDefined('shopify-payment-terms').then(() => { + const installmentsContent = document.querySelector('shopify-payment-terms')?.shadowRoot; + const cta = installmentsContent?.querySelector('#shopify-installments-cta'); + cta?.addEventListener('click', this.closeDialog, { once: true }); + }); + } + + close() { + this.closeDialog(); + } + + #updateStickyState() { + const { dialog } = /** @type {Refs} */ (this.refs); + if (!dialog) return; + + // Refs do not cross nested `*-component` boundaries (e.g., `cart-items-component`), so we query within the dialog. + const content = dialog.querySelector('.cart-drawer__content'); + const summary = dialog.querySelector('.cart-drawer__summary'); + + if (!content || !summary) { + // Ensure the dialog doesn't get stuck in "unsticky" mode when summary disappears (e.g., empty cart). + dialog.setAttribute('cart-summary-sticky', 'false'); + return; + } + + const drawerHeight = dialog.getBoundingClientRect().height; + const summaryHeight = summary.getBoundingClientRect().height; + const ratio = summaryHeight / drawerHeight; + dialog.setAttribute('cart-summary-sticky', ratio > this.#summaryThreshold ? 'false' : 'true'); + } +} + +if (!customElements.get('cart-drawer-component')) { + customElements.define('cart-drawer-component', CartDrawerComponent); +} diff --git a/assets/cart-icon.js b/assets/cart-icon.js new file mode 100644 index 000000000..ab372f048 --- /dev/null +++ b/assets/cart-icon.js @@ -0,0 +1,134 @@ +import { Component } from '@theme/component'; +import { onAnimationEnd } from '@theme/utilities'; +import { ThemeEvents, CartUpdateEvent } from '@theme/events'; + +/** + * A custom element that displays a cart icon. + * + * @typedef {object} Refs + * @property {HTMLElement} cartBubble - The cart bubble element. + * @property {HTMLElement} cartBubbleText - The cart bubble text element. + * @property {HTMLElement} cartBubbleCount - The cart bubble count element. + * + * @extends {Component} + */ +class CartIcon extends Component { + requiredRefs = ['cartBubble', 'cartBubbleText', 'cartBubbleCount']; + + /** @type {number} */ + get currentCartCount() { + return parseInt(this.refs.cartBubbleCount.textContent ?? '0', 10); + } + + set currentCartCount(value) { + this.refs.cartBubbleCount.textContent = value < 100 ? String(value) : ''; + } + + connectedCallback() { + super.connectedCallback(); + + document.addEventListener(ThemeEvents.cartUpdate, this.onCartUpdate); + window.addEventListener('pageshow', this.onPageShow); + this.ensureCartBubbleIsCorrect(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + document.removeEventListener(ThemeEvents.cartUpdate, this.onCartUpdate); + window.removeEventListener('pageshow', this.onPageShow); + } + + /** + * Handles the page show event when the page is restored from cache. + * @param {PageTransitionEvent} event - The page show event. + */ + onPageShow = (event) => { + if (event.persisted) { + this.ensureCartBubbleIsCorrect(); + } + }; + + /** + * Handles the cart update event. + * @param {CartUpdateEvent} event - The cart update event. + */ + onCartUpdate = async (event) => { + const itemCount = event.detail.data?.itemCount ?? 0; + const comingFromProductForm = event.detail.data?.source === 'product-form-component'; + + this.renderCartBubble(itemCount, comingFromProductForm); + }; + + /** + * Renders the cart bubble. + * @param {number} itemCount - The number of items in the cart. + * @param {boolean} comingFromProductForm - Whether the cart update is coming from the product form. + */ + renderCartBubble = async (itemCount, comingFromProductForm, animate = true) => { + // If the cart update is coming from the product form, we add to the current cart count, otherwise we set the new cart count + + this.refs.cartBubbleCount.classList.toggle('hidden', itemCount === 0); + this.refs.cartBubble.classList.toggle('visually-hidden', itemCount === 0); + + this.currentCartCount = comingFromProductForm ? this.currentCartCount + itemCount : itemCount; + + this.classList.toggle('header-actions__cart-icon--has-cart', itemCount > 0); + + sessionStorage.setItem( + 'cart-count', + JSON.stringify({ + value: String(this.currentCartCount), + timestamp: Date.now(), + }) + ); + + if (!animate || itemCount === 0) return; + + // Ensure element is visible before starting animation + // Use requestAnimationFrame to ensure the browser sees the state change + await new Promise((resolve) => requestAnimationFrame(resolve)); + + this.refs.cartBubble.classList.add('cart-bubble--animating'); + await onAnimationEnd(this.refs.cartBubbleText); + + this.refs.cartBubble.classList.remove('cart-bubble--animating'); + }; + + /** + * Checks if the cart count is correct. + */ + ensureCartBubbleIsCorrect = () => { + // Ensure refs are available + if (!this.refs.cartBubbleCount) return; + + const sessionStorageCount = sessionStorage.getItem('cart-count'); + + // If no session storage data, nothing to check + if (sessionStorageCount === null) return; + + const visibleCount = this.refs.cartBubbleCount.textContent; + + try { + const { value, timestamp } = JSON.parse(sessionStorageCount); + + // Check if the stored count matches what's visible + if (value === visibleCount) return; + + // Only update if timestamp is recent (within 10 seconds) + if (Date.now() - timestamp < 10000) { + const count = parseInt(value, 10); + + if (count >= 0) { + this.renderCartBubble(count, false, false); + } + } + } catch (_) { + // no-op + } + }; +} + +if (!customElements.get('cart-icon')) { + customElements.define('cart-icon', CartIcon); +} diff --git a/assets/cart-note.js b/assets/cart-note.js new file mode 100644 index 000000000..36f7824db --- /dev/null +++ b/assets/cart-note.js @@ -0,0 +1,46 @@ +import { Component } from '@theme/component'; +import { debounce, fetchConfig } from '@theme/utilities'; +import { cartPerformance } from '@theme/performance'; + +/** + * A custom element that displays a cart note. + */ +class CartNote extends Component { + /** @type {AbortController | null} */ + #activeFetch = null; + + /** + * Handles updates to the cart note. + * @param {InputEvent} event - The input event in our text-area. + */ + updateCartNote = debounce(async (event) => { + if (!(event.target instanceof HTMLTextAreaElement)) return; + + const note = event.target.value; + if (this.#activeFetch) { + this.#activeFetch.abort(); + } + + const abortController = new AbortController(); + this.#activeFetch = abortController; + + try { + const config = fetchConfig('json', { + body: JSON.stringify({ note }), + }); + + await fetch(Theme.routes.cart_update_url, { + ...config, + signal: abortController.signal, + }); + } catch (error) { + } finally { + this.#activeFetch = null; + cartPerformance.measureFromEvent('note-update:user-action', event); + } + }, 200); +} + +if (!customElements.get('cart-note')) { + customElements.define('cart-note', CartNote); +} diff --git a/assets/collection-links.js b/assets/collection-links.js new file mode 100644 index 000000000..56a0ed4ba --- /dev/null +++ b/assets/collection-links.js @@ -0,0 +1,232 @@ +import { Component } from '@theme/component'; +import { closest, clamp, center, getVisibleElements } from '@theme/utilities'; +import { SlideshowSelectEvent } from '@theme/events'; +import { Scroller } from '@theme/scrolling'; +import { cycleFocus } from '@theme/focus'; + +/** + * Collection links component + * + * @typedef {Object} Refs + * @property {HTMLElement} container + * @property {HTMLElement[]} [images] + * @property {HTMLElement[]} [links] + * @property {import('slideshow').Slideshow} slideshow + * + * @extends {Component} + */ +class CollectionLinks extends Component { + requiredRefs = ['container']; + + /** @type {Scroller} */ + #scroll; + + connectedCallback() { + super.connectedCallback(); + + this.addEventListener('keydown', this.#handleKeydown); + this.addEventListener(SlideshowSelectEvent.eventName, this.#handleSlideshowSelect); + + this.#scroll = new Scroller(this.refs.container, { onScroll: this.#handleScroll }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this.#scroll.destroy(); + } + + get links() { + return this.refs.links || []; + } + + get currentIndex() { + return this.links.findIndex((link) => link.getAttribute('aria-current') === 'true'); + } + + /** + * Public method to select a collection link + * + * @param {number} targetIndex + * @param {PointerEvent} [event] + */ + select(targetIndex, event) { + this.#updateSelectedLink(targetIndex); + this.refs.slideshow?.select(targetIndex, undefined, { animate: false }); + if (event) this.#revealImage(event); + } + + /** + * Update the selected link + * + * @param {number} index + */ + #updateSelectedLink(index) { + const { links } = this; + const selectedIndex = clamp(index, 0, links.length - 1); + + for (const [index, link] of links.entries()) { + link.setAttribute('aria-current', Boolean(index === selectedIndex).toString()); + } + } + + /** + * Handle slideshow select event + * + * @param {SlideshowSelectEvent} event + */ + #handleSlideshowSelect = async (event) => { + if (!event.detail.userInitiated) return; + + const { index } = event.detail; + if (index === this.currentIndex) return; + + const selectedLink = this.links[index]; + if (!selectedLink) return; + + this.#updateSelectedLink(index); + + this.#scroll.to(selectedLink); + }; + + /** + * Cycle focus to the next or previous link + * + * @param {KeyboardEvent} event + */ + #handleKeydown(event) { + let modifier = 0; + + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + modifier = 1; + break; + case 'ArrowLeft': + case 'ArrowUp': + modifier = -1; + break; + } + + if (!modifier) return; + + event.preventDefault(); + cycleFocus(this.links, modifier); + } + + /** + * Handle scroll event + */ + #handleScroll = () => { + const { links } = this; + const { container } = this.refs; + const visibleLinks = getVisibleElements(this, links, 0.1); + + if (visibleLinks.length === 0) return; + const centers = visibleLinks.map((link) => center(link, 'x')); + const referencePoint = center(container, 'x'); + const closestCenter = closest(centers, referencePoint); + const closestVisibleLink = visibleLinks[centers.indexOf(closestCenter)]; + + if (!closestVisibleLink) return; + + const index = links.indexOf(closestVisibleLink); + + this.select(index); + }; + + /** + * Clear all selections + */ + clearSelections = () => { + // Clear all selections when mouse leaves container + const { links } = this; + const { images } = this.refs; + + // Reset all links to unselected state (opacity will reset via CSS) + for (const link of links) { + link.setAttribute('aria-current', 'false'); + } + + // Hide any revealed images + if (images) { + for (const image of images) { + image.removeAttribute('reveal'); + } + } + }; + + /** + * Reveal an image + * + * @param {Event} event + */ + #revealImage(event) { + if (!(event instanceof PointerEvent)) return; + if (event.pointerType === 'touch') return; + + const { target } = event; + if (!(target instanceof HTMLElement)) return; + + const { images } = this.refs; + const index = this.links.indexOf(target); + const selectedImage = images?.[index]; + + if (!selectedImage) return; + + // Cache image dimensions to avoid repeated layout reads + let cachedImageHeight = selectedImage.offsetHeight; + let cachedImageWidth = selectedImage.offsetWidth; + + /** @type {number | null} */ + let rafId = null; + + /** @param {PointerEvent} event */ + const updateImagePosition = (event) => { + // Throttle with requestAnimationFrame to avoid layout thrashing + if (rafId !== null) return; + + rafId = requestAnimationFrame(() => { + rafId = null; + + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const offset = 15; + + const wouldBeCutOff = event.clientY + cachedImageHeight + offset > viewportHeight; + const yPos = wouldBeCutOff ? event.clientY - cachedImageHeight - offset : event.clientY + offset; + + const xPos = Math.min(Math.max(offset, event.clientX + offset), viewportWidth - cachedImageWidth - offset); + + selectedImage.style.setProperty('--x', `${xPos}px`); + selectedImage.style.setProperty('--y', `${yPos}px`); + }); + }; + + const reset = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + selectedImage.removeAttribute('reveal'); + target.removeEventListener('mousemove', updateImagePosition); + }; + + updateImagePosition(event); + + for (const image of images) { + if (image === selectedImage) { + image.setAttribute('reveal', ''); + } else { + image.removeAttribute('reveal'); + } + } + + target.addEventListener('mousemove', updateImagePosition); + target.addEventListener('mouseleave', reset, { once: true }); + } +} + +if (!customElements.get('collection-links-component')) { + customElements.define('collection-links-component', CollectionLinks); +} diff --git a/assets/comparison-slider.js b/assets/comparison-slider.js new file mode 100644 index 000000000..e5589ab46 --- /dev/null +++ b/assets/comparison-slider.js @@ -0,0 +1,157 @@ +import { Component } from '@theme/component'; +import { oncePerEditorSession } from '@theme/utilities'; + +/** + * Comparison slider component for comparing two images + * + * @typedef {object} ComparisonSliderRefs + * @property {HTMLElement} mediaWrapper - The container for the images + * @property {HTMLInputElement} slider - The range input element + * @property {HTMLElement} afterImage - The image that gets revealed + * + * @extends {Component} + * + * @property {string[]} requiredRefs - Required refs: 'mediaWrapper', 'slider', and 'afterImage' + */ +export class ComparisonSliderComponent extends Component { + requiredRefs = ['mediaWrapper', 'slider', 'afterImage']; + + constructor() { + super(); + this.hasAnimated = false; + this.boundHandleIntersection = this.handleIntersection.bind(this); + } + + /** + * Called when component is added to DOM + */ + connectedCallback() { + super.connectedCallback(); + + const { mediaWrapper } = this.refs; + + // Get orientation from media wrapper + this.orientation = mediaWrapper.dataset.orientation || 'horizontal'; + + // Initialize the position + this.sync(); + + // Set up intersection observer for animation + this.setupIntersectionObserver(); + } + + /** + * Sync the CSS custom property with the input value + */ + sync() { + const { mediaWrapper, slider } = this.refs; + // Skip sync during animation to prevent lag + if (this.isAnimating) return; + + const val = (Number(slider.value) - Number(slider.min)) / (Number(slider.max) - Number(slider.min)); + const compareValue = Math.round(val * 100); + + // Set the CSS custom property on the media wrapper + mediaWrapper.style.setProperty('--compare', String(compareValue)); + } + + /** + * Clean up when component is removed + */ + disconnectedCallback() { + // Clean up intersection observer + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + } + } + + /** + * Set the slider value and update display + * @param {number} value - Value between 0-100 (0 = all after, 100 = all before) + */ + setValue(value) { + const { slider } = this.refs; + if (!slider) return; + + slider.value = String(value); + this.sync(); + } + + /** + * Animate the slider handle to give users a hint about the interaction + */ + animateSlider() { + const { mediaWrapper, slider } = this.refs; + if (this.hasAnimated) return; + + this.hasAnimated = true; + this.isAnimating = true; + + // Enable transition for smooth animation + mediaWrapper.style.setProperty('--transition-duration', '0.5s'); + + // Create a subtle sliding animation by only setting CSS property + setTimeout(() => { + mediaWrapper.style.setProperty('--compare', '40'); + }, 100); + + setTimeout(() => { + mediaWrapper.style.setProperty('--compare', '60'); + }, 600); + + setTimeout(() => { + mediaWrapper.style.setProperty('--compare', '50'); + }, 1100); + + setTimeout(() => { + // Remove transition after animation and sync slider value + mediaWrapper.style.setProperty('--transition-duration', '0s'); + // Sync the slider value to match the final position + slider.value = '50'; + this.isAnimating = false; + }, 1600); + } + + /** + * Set up intersection observer to detect when section comes into view + */ + setupIntersectionObserver() { + if (!window.IntersectionObserver) return; + + const options = { + root: null, + rootMargin: '0px', + threshold: 0.5, // Trigger when 50% of the component is visible + }; + + this.intersectionObserver = new IntersectionObserver(this.boundHandleIntersection, options); + this.intersectionObserver.observe(this); + } + + /** + * Handle intersection observer callback + * @param {IntersectionObserverEntry[]} entries + */ + handleIntersection(entries) { + entries.forEach((entry) => { + if (entry.isIntersecting && !this.hasAnimated) { + // Add a small delay to ensure everything is rendered + setTimeout(() => { + oncePerEditorSession(this, `comparison-slider-animated`, () => { + this.animateSlider(); + }); + }, 300); + + // Disconnect observer after first animation + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + } + } + }); + } +} + +// Register the custom element +if (!customElements.get('comparison-slider-component')) { + customElements.define('comparison-slider-component', ComparisonSliderComponent); +} diff --git a/assets/component-cart-items.js b/assets/component-cart-items.js new file mode 100644 index 000000000..ceddb4d5b --- /dev/null +++ b/assets/component-cart-items.js @@ -0,0 +1,333 @@ +import { Component } from '@theme/component'; +import { + fetchConfig, + debounce, + onAnimationEnd, + prefersReducedMotion, + resetShimmer, + startViewTransition, +} from '@theme/utilities'; +import { morphSection, sectionRenderer } from '@theme/section-renderer'; +import { + ThemeEvents, + CartUpdateEvent, + QuantitySelectorUpdateEvent, + CartAddEvent, + DiscountUpdateEvent, +} from '@theme/events'; +import { cartPerformance } from '@theme/performance'; + +/** @typedef {import('./utilities').TextComponent} TextComponent */ + +/** + * A custom element that displays a cart items component. + * + * @typedef {object} Refs + * @property {HTMLElement[]} quantitySelectors - The quantity selector elements. + * @property {HTMLTableRowElement[]} cartItemRows - The cart item rows. + * @property {TextComponent} cartTotal - The cart total. + * + * @extends {Component} + */ +class CartItemsComponent extends Component { + #debouncedOnChange = debounce(this.#onQuantityChange, 300).bind(this); + + connectedCallback() { + super.connectedCallback(); + + document.addEventListener(ThemeEvents.cartUpdate, this.#handleCartUpdate); + document.addEventListener(ThemeEvents.discountUpdate, this.handleDiscountUpdate); + document.addEventListener(ThemeEvents.quantitySelectorUpdate, this.#debouncedOnChange); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + document.removeEventListener(ThemeEvents.cartUpdate, this.#handleCartUpdate); + document.removeEventListener(ThemeEvents.quantitySelectorUpdate, this.#debouncedOnChange); + } + + /** + * Handles QuantitySelectorUpdateEvent change event. + * @param {QuantitySelectorUpdateEvent} event - The event. + */ + #onQuantityChange(event) { + if (!(event.target instanceof Node) || !this.contains(event.target)) return; + + const { quantity, cartLine: line } = event.detail; + + // Cart items require a line number + if (!line) return; + + if (quantity === 0) { + return this.onLineItemRemove(line); + } + + this.updateQuantity({ + line, + quantity, + action: 'change', + }); + const lineItemRow = this.refs.cartItemRows[line - 1]; + + if (!lineItemRow) return; + + const textComponent = /** @type {TextComponent | undefined} */ (lineItemRow.querySelector('text-component')); + textComponent?.shimmer(); + } + + /** + * Handles the line item removal. + * @param {number} line - The line item index. + */ + onLineItemRemove(line) { + this.updateQuantity({ + line, + quantity: 0, + action: 'clear', + }); + + const cartItemRowToRemove = this.refs.cartItemRows[line - 1]; + + if (!cartItemRowToRemove) return; + + const rowsToRemove = [ + cartItemRowToRemove, + // Get all nested lines of the row to remove + ...this.refs.cartItemRows.filter((row) => row.dataset.parentKey === cartItemRowToRemove.dataset.key), + ]; + + // If the cart item row is the last row, optimistically trigger the cart empty state + const isEmptyCart = rowsToRemove.length == this.refs.cartItemRows.length; + + const template = document.getElementById('empty-cart-template'); + if (isEmptyCart && template instanceof HTMLTemplateElement) { + const clone = document.importNode(template.content, true); + + startViewTransition(() => { + this.replaceChildren(clone); + }, [this.isDrawer ? 'empty-cart-drawer' : 'empty-cart-page']); + + return; + } + + // Add class to the row to trigger the animation + rowsToRemove.forEach((row) => { + const remove = () => row.remove(); + + if (prefersReducedMotion()) return remove(); + + row.style.setProperty('--row-height', `${row.clientHeight}px`); + row.classList.add('removing'); + + // Remove the row after the animation ends + onAnimationEnd(row, remove); + }); + } + + /** + * Updates the quantity. + * @param {Object} config - The config. + * @param {number} config.line - The line. + * @param {number} config.quantity - The quantity. + * @param {string} config.action - The action. + */ + updateQuantity(config) { + const cartPerformaceUpdateMarker = cartPerformance.createStartingMarker(`${config.action}:user-action`); + + this.#disableCartItems(); + + const { line, quantity } = config; + const { cartTotal } = this.refs; + + const cartItemsComponents = document.querySelectorAll('cart-items-component'); + const sectionsToUpdate = new Set([this.sectionId]); + cartItemsComponents.forEach((item) => { + if (item instanceof HTMLElement && item.dataset.sectionId) { + sectionsToUpdate.add(item.dataset.sectionId); + } + }); + + const body = JSON.stringify({ + line: line, + quantity: quantity, + sections: Array.from(sectionsToUpdate).join(','), + sections_url: window.location.pathname, + }); + + cartTotal?.shimmer(); + + fetch(`${Theme.routes.cart_change_url}`, fetchConfig('json', { body })) + .then((response) => { + return response.text(); + }) + .then((responseText) => { + const parsedResponseText = JSON.parse(responseText); + + resetShimmer(this); + + if (parsedResponseText.errors) { + this.#handleCartError(line, parsedResponseText); + return; + } + + const newSectionHTML = new DOMParser().parseFromString( + parsedResponseText.sections[this.sectionId], + 'text/html' + ); + + // Grab the new cart item count from a hidden element + const newCartHiddenItemCount = newSectionHTML.querySelector('[ref="cartItemCount"]')?.textContent; + const newCartItemCount = newCartHiddenItemCount ? parseInt(newCartHiddenItemCount, 10) : 0; + + // Update data-cart-quantity for all matching variants + this.#updateQuantitySelectors(parsedResponseText); + + this.dispatchEvent( + new CartUpdateEvent(parsedResponseText, this.sectionId, { + itemCount: newCartItemCount, + source: 'cart-items-component', + sections: parsedResponseText.sections, + }) + ); + + morphSection(this.sectionId, parsedResponseText.sections[this.sectionId], this.isDrawer ? 'hydration' : 'full'); + + this.#updateCartQuantitySelectorButtonStates(); + }) + .catch((error) => { + console.error(error); + }) + .finally(() => { + this.#enableCartItems(); + cartPerformance.measureFromMarker(cartPerformaceUpdateMarker); + }); + } + + /** + * Handles the discount update. + * @param {DiscountUpdateEvent} event - The event. + */ + handleDiscountUpdate = (event) => { + this.#handleCartUpdate(event); + }; + + /** + * Handles the cart error. + * @param {number} line - The line. + * @param {Object} parsedResponseText - The parsed response text. + * @param {string} parsedResponseText.errors - The errors. + */ + #handleCartError = (line, parsedResponseText) => { + const quantitySelector = this.refs.quantitySelectors[line - 1]; + const quantityInput = quantitySelector?.querySelector('input'); + + if (!quantityInput) throw new Error('Quantity input not found'); + + quantityInput.value = quantityInput.defaultValue; + + const cartItemError = this.refs[`cartItemError-${line}`]; + const cartItemErrorContainer = this.refs[`cartItemErrorContainer-${line}`]; + + if (!(cartItemError instanceof HTMLElement)) throw new Error('Cart item error not found'); + if (!(cartItemErrorContainer instanceof HTMLElement)) throw new Error('Cart item error container not found'); + + cartItemError.textContent = parsedResponseText.errors; + cartItemErrorContainer.classList.remove('hidden'); + }; + + /** + * Handles the cart update. + * + * @param {DiscountUpdateEvent | CartUpdateEvent | CartAddEvent} event + */ + #handleCartUpdate = (event) => { + if (event instanceof DiscountUpdateEvent) { + sectionRenderer.renderSection(this.sectionId, { cache: false }); + return; + } + if (event.target === this) return; + + const cartItemsHtml = event.detail.data.sections?.[this.sectionId]; + if (cartItemsHtml) { + morphSection(this.sectionId, cartItemsHtml); + + // Update button states for all cart quantity selectors after morph + this.#updateCartQuantitySelectorButtonStates(); + } else { + sectionRenderer.renderSection(this.sectionId, { cache: false }); + } + }; + + /** + * Disables the cart items. + */ + #disableCartItems() { + this.classList.add('cart-items-disabled'); + } + + /** + * Enables the cart items. + */ + #enableCartItems() { + this.classList.remove('cart-items-disabled'); + } + + /** + * Updates quantity selectors for all matching variants in the cart. + * @param {Object} updatedCart - The updated cart object. + * @param {Array<{variant_id: number, quantity: number}>} [updatedCart.items] - The cart items. + */ + #updateQuantitySelectors(updatedCart) { + if (!updatedCart.items) return; + + for (const item of updatedCart.items) { + const variantId = item.variant_id.toString(); + const selectors = document.querySelectorAll(`quantity-selector-component[data-variant-id="${variantId}"]`); + + for (const selector of selectors) { + const input = selector.querySelector('input[data-cart-quantity]'); + if (!input) continue; + + input.setAttribute('data-cart-quantity', item.quantity.toString()); + + // Update the quantity selector's internal state + if ('updateCartQuantity' in selector && typeof selector.updateCartQuantity === 'function') { + selector.updateCartQuantity(); + } + } + } + } + + /** + * Updates button states for all cart quantity selector components. + */ + #updateCartQuantitySelectorButtonStates() { + for (const selector of document.querySelectorAll('cart-quantity-selector-component')) { + /** @type {any} */ (selector).updateButtonStates?.(); + } + } + + /** + * Gets the section id. + * @returns {string} The section id. + */ + get sectionId() { + const { sectionId } = this.dataset; + + if (!sectionId) throw new Error('Section id missing'); + + return sectionId; + } + + /** + * @returns {boolean} Whether the component is a drawer. + */ + get isDrawer() { + return this.dataset.drawer !== undefined; + } +} + +if (!customElements.get('cart-items-component')) { + customElements.define('cart-items-component', CartItemsComponent); +} diff --git a/assets/component-cart-quantity-selector.js b/assets/component-cart-quantity-selector.js new file mode 100644 index 000000000..dba69f188 --- /dev/null +++ b/assets/component-cart-quantity-selector.js @@ -0,0 +1,38 @@ +import { QuantitySelectorComponent } from '@theme/component-quantity-selector'; + +/** + * A custom element that allows the user to select a quantity in the cart. + * Extends QuantitySelectorComponent but uses absolute max limits instead of effective max. + * Semantics: "What should the total quantity BE in the cart" vs "How many to ADD to cart" + * + * @extends {QuantitySelectorComponent} + */ +class CartQuantitySelectorComponent extends QuantitySelectorComponent { + /** + * Gets the effective maximum value for cart quantity selector + * Cart page: uses absolute max (how much can be in cart total) + * @returns {number | null} The effective max, or null if no max + */ + getEffectiveMax() { + const { max } = this.getCurrentValues(); + return max; // Cart uses absolute max, not max minus cart quantity + } + + /** + * Updates button states based on current value and limits + * Cart buttons are always managed client-side, never server-disabled + */ + updateButtonStates() { + const { minusButton, plusButton } = this.refs; + const { min, value } = this.getCurrentValues(); + const effectiveMax = this.getEffectiveMax(); + + // Cart buttons are always dynamically managed + minusButton.disabled = value <= min; + plusButton.disabled = effectiveMax !== null && value >= effectiveMax; + } +} + +if (!customElements.get('cart-quantity-selector-component')) { + customElements.define('cart-quantity-selector-component', CartQuantitySelectorComponent); +} diff --git a/assets/component-quantity-selector.js b/assets/component-quantity-selector.js new file mode 100644 index 000000000..2f904e865 --- /dev/null +++ b/assets/component-quantity-selector.js @@ -0,0 +1,297 @@ +import { Component } from '@theme/component'; +import { QuantitySelectorUpdateEvent } from '@theme/events'; +import { parseIntOrDefault } from '@theme/utilities'; + +/** + * A custom element that allows the user to select a quantity. + * + * This component follows a pure event-driven architecture where quantity changes + * are broadcast via QuantitySelectorUpdateEvent. Parent components that contain + * quantity selectors listen for these events and handle them according to their + * specific needs, with event filtering ensuring each parent only processes events + * from its own quantity selectors to prevent conflicts between different cart + * update strategies. + * + * @typedef {Object} Refs + * @property {HTMLInputElement} quantityInput + * @property {HTMLButtonElement} minusButton + * @property {HTMLButtonElement} plusButton + * + * @extends {Component} + */ +export class QuantitySelectorComponent extends Component { + requiredRefs = ['quantityInput', 'minusButton', 'plusButton']; + serverDisabledMinus = false; + serverDisabledPlus = false; + initialized = false; + + connectedCallback() { + super.connectedCallback(); + + // Capture server-disabled state on first load + const { minusButton, plusButton } = this.refs; + + if (minusButton.disabled) { + this.serverDisabledMinus = true; + } + if (plusButton.disabled) { + this.serverDisabledPlus = true; + } + + this.initialized = true; + this.updateButtonStates(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + /** + * Updates cart quantity and refreshes component state + * @param {number} cartQty - The quantity currently in cart for this variant + */ + setCartQuantity(cartQty) { + this.refs.quantityInput.setAttribute('data-cart-quantity', cartQty.toString()); + this.updateCartQuantity(); + } + + /** + * Checks if the current quantity can be added to cart without exceeding max + * @returns {{canAdd: boolean, maxQuantity: number|null, cartQuantity: number, quantityToAdd: number}} Validation result + */ + canAddToCart() { + const { max, cartQuantity, value } = this.getCurrentValues(); + const quantityToAdd = value; + const wouldExceedMax = max !== null && cartQuantity + quantityToAdd > max; + + return { + canAdd: !wouldExceedMax, + maxQuantity: max, + cartQuantity, + quantityToAdd, + }; + } + + /** + * Gets the current quantity value + * @returns {string} The current value + */ + getValue() { + return this.refs.quantityInput.value; + } + + /** + * Sets the current quantity value + * @param {string} value - The value to set + */ + setValue(value) { + this.refs.quantityInput.value = value; + } + + /** + * Updates min/max/step constraints and snaps value to valid increment + * @param {string} min - Minimum value + * @param {string|null} max - Maximum value (null if no max) + * @param {string} step - Step increment + */ + updateConstraints(min, max, step) { + const { quantityInput } = this.refs; + const currentValue = parseInt(quantityInput.value) || 0; + + quantityInput.min = min; + if (max) { + quantityInput.max = max; + } else { + quantityInput.removeAttribute('max'); + } + quantityInput.step = step; + + const newMin = parseIntOrDefault(min, 1); + const newStep = parseIntOrDefault(step, 1); + const effectiveMax = this.getEffectiveMax(); + + // Snap to valid increment if not already aligned + let newValue = currentValue; + if ((currentValue - newMin) % newStep !== 0) { + // Snap DOWN to closest valid increment + newValue = newMin + Math.floor((currentValue - newMin) / newStep) * newStep; + } + + // Ensure value is within bounds + newValue = Math.max(newMin, Math.min(effectiveMax ?? Infinity, newValue)); + + if (newValue !== currentValue) { + quantityInput.value = newValue.toString(); + } + + this.updateButtonStates(); + } + + /** + * Gets current values from DOM (fresh read every time) + * @returns {{min: number, max: number|null, step: number, value: number, cartQuantity: number}} + */ + getCurrentValues() { + const { quantityInput } = this.refs; + + return { + min: parseIntOrDefault(quantityInput.min, 1), + max: parseIntOrDefault(quantityInput.max, null), + step: parseIntOrDefault(quantityInput.step, 1), + value: parseIntOrDefault(quantityInput.value, 0), + cartQuantity: parseIntOrDefault(quantityInput.getAttribute('data-cart-quantity'), 0), + }; + } + + /** + * Gets the effective maximum value for this quantity selector + * Product page: max - cartQuantity (how many can be added) + * Override in subclass for different behavior + * @returns {number | null} The effective max, or null if no max + */ + getEffectiveMax() { + const { max, cartQuantity, min } = this.getCurrentValues(); + if (max === null) return null; + // Product page: can only add what's left + return Math.max(max - cartQuantity, min); + } + + /** + * Updates button states based on current value and limits + */ + updateButtonStates() { + const { minusButton, plusButton } = this.refs; + const { min, value } = this.getCurrentValues(); + const effectiveMax = this.getEffectiveMax(); + + // Only manage buttons that weren't server-disabled + if (!this.serverDisabledMinus) { + minusButton.disabled = value <= min; + } + + if (!this.serverDisabledPlus) { + plusButton.disabled = effectiveMax !== null && value >= effectiveMax; + } + } + + /** + * Updates quantity by a given step + * @param {number} stepMultiplier - Positive for increase, negative for decrease + */ + updateQuantity(stepMultiplier) { + const { quantityInput } = this.refs; + const { min, step, value } = this.getCurrentValues(); + const effectiveMax = this.getEffectiveMax(); + + const newValue = Math.min(effectiveMax ?? Infinity, Math.max(min, value + step * stepMultiplier)); + + quantityInput.value = newValue.toString(); + this.onQuantityChange(); + this.updateButtonStates(); + } + + /** + * Handles the quantity increase event. + * @param {Event} event - The event. + */ + increaseQuantity(event) { + if (!(event.target instanceof HTMLElement)) return; + event.preventDefault(); + this.updateQuantity(1); + } + + /** + * Handles the quantity decrease event. + * @param {Event} event - The event. + */ + decreaseQuantity(event) { + if (!(event.target instanceof HTMLElement)) return; + event.preventDefault(); + this.updateQuantity(-1); + } + + /** + * When our input gets focused, we want to fully select the value. + * @param {FocusEvent} event + */ + selectInputValue(event) { + const { quantityInput } = this.refs; + if (!(event.target instanceof HTMLInputElement) || document.activeElement !== quantityInput) return; + + quantityInput.select(); + } + + /** + * Handles the quantity set event (on blur). + * Validates and snaps to valid values. + * @param {Event} event - The event. + */ + setQuantity(event) { + if (!(event.target instanceof HTMLInputElement)) return; + + event.preventDefault(); + const { quantityInput } = this.refs; + const { min, step } = this.getCurrentValues(); + const effectiveMax = this.getEffectiveMax(); + + // Snap to bounds + const quantity = Math.min(effectiveMax ?? Infinity, Math.max(min, parseInt(event.target.value) || 0)); + + // Validate step increment + if ((quantity - min) % step !== 0) { + // Set the invalid value and trigger native HTML validation + quantityInput.value = quantity.toString(); + quantityInput.reportValidity(); + return; + } + + quantityInput.value = quantity.toString(); + this.onQuantityChange(); + this.updateButtonStates(); + } + + /** + * Handles the quantity change event. + */ + onQuantityChange() { + const { quantityInput } = this.refs; + const newValue = parseInt(quantityInput.value); + + this.dispatchEvent(new QuantitySelectorUpdateEvent(newValue, Number(quantityInput.dataset.cartLine) || undefined)); + } + + /** + * Updates the cart quantity from data attribute and refreshes button states + * Called when cart is updated from external sources + */ + updateCartQuantity() { + const { quantityInput } = this.refs; + const { min, value } = this.getCurrentValues(); + const effectiveMax = this.getEffectiveMax(); + + // Clamp value to new effective max if necessary + const clampedValue = Math.min(effectiveMax ?? Infinity, Math.max(min, value)); + + if (clampedValue !== value) { + quantityInput.value = clampedValue.toString(); + } + + this.updateButtonStates(); + } + + /** + * Gets the quantity input. + * @returns {HTMLInputElement} The quantity input. + */ + get quantityInput() { + if (!this.refs.quantityInput) { + throw new Error('Missing inside '); + } + + return this.refs.quantityInput; + } +} + +if (!customElements.get('quantity-selector-component')) { + customElements.define('quantity-selector-component', QuantitySelectorComponent); +} diff --git a/assets/component.js b/assets/component.js new file mode 100644 index 000000000..def9b785a --- /dev/null +++ b/assets/component.js @@ -0,0 +1,348 @@ +import { requestIdleCallback } from '@theme/utilities'; + +/* + * Declarative shadow DOM is only initialized on the initial render of the page. + * If the component is mounted after the browser finishes the initial render, + * the shadow root needs to be manually hydrated. + */ +export class DeclarativeShadowElement extends HTMLElement { + connectedCallback() { + if (!this.shadowRoot) { + const template = this.querySelector(':scope > template[shadowrootmode="open"]'); + + if (!(template instanceof HTMLTemplateElement)) return; + + const shadow = this.attachShadow({ mode: 'open' }); + shadow.append(template.content.cloneNode(true)); + } + } +} + +/** + * @typedef {Record} Refs + */ + +/** + * @template {Refs} T + * @typedef {T & Refs} RefsType + */ + +/** + * Base class that powers our custom web components. + * + * Manages references to child elements with `ref` attributes and sets up mutation observers to keep + * the refs updated when the DOM changes. Also handles declarative event listeners using. + * + * @template {Refs} [T=Refs] + * @extends {DeclarativeShadowElement} + */ +export class Component extends DeclarativeShadowElement { + /** + * An object holding references to child elements with `ref` attributes. + * + * @type {RefsType} + */ + refs = /** @type {RefsType} */ ({}); + + /** + * An array of required refs. If a ref is not found, an error will be thrown. + * + * @type {string[] | undefined} + */ + requiredRefs; + + /** + * Gets the root node of the component, which is either its shadow root or the component itself. + * + * @returns {(ShadowRoot | Component)[]} The root nodes. + */ + get roots() { + return this.shadowRoot ? [this, this.shadowRoot] : [this]; + } + + /** + * Called when the element is connected to the document's DOM. + * + * Initializes event listeners and refs. + */ + connectedCallback() { + super.connectedCallback(); + registerEventListeners(); + + this.#updateRefs(); + + requestIdleCallback(() => { + for (const root of this.roots) { + this.#mutationObserver.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['ref'], + attributeOldValue: true, + }); + } + }); + } + + /** + * Called when the element is re-rendered by the Section Rendering API. + */ + updatedCallback() { + this.#mutationObserver.takeRecords(); + this.#updateRefs(); + } + + /** + * Called when the element is disconnected from the document's DOM. + * + * Disconnects the mutation observer. + */ + disconnectedCallback() { + this.#mutationObserver.disconnect(); + } + + /** + * Updates the `refs` object by querying all descendant elements with `ref` attributes and storing references to them. + * + * This method is called to keep the `refs` object in sync with the DOM. + */ + #updateRefs() { + const refs = /** @type any */ ({}); + const elements = this.roots.reduce((acc, root) => { + for (const element of root.querySelectorAll('[ref]')) { + if (!this.#isDescendant(element)) continue; + acc.add(element); + } + + return acc; + }, /** @type {Set} */ (new Set())); + + for (const ref of elements) { + const refName = ref.getAttribute('ref') ?? ''; + const isArray = refName.endsWith('[]'); + const path = isArray ? refName.slice(0, -2) : refName; + + if (isArray) { + const array = Array.isArray(refs[path]) ? refs[path] : []; + + array.push(ref); + refs[path] = array; + } else { + refs[path] = ref; + } + } + + if (this.requiredRefs?.length) { + for (const ref of this.requiredRefs) { + if (!(ref in refs)) { + throw new MissingRefError(ref, this); + } + } + } + + this.refs = /** @type {RefsType} */ (refs); + } + + /** + * MutationObserver instance to observe changes in the component's DOM subtree and update refs accordingly. + * + * @type {MutationObserver} + */ + #mutationObserver = new MutationObserver((mutations) => { + if ( + mutations.some( + (m) => + (m.type === 'attributes' && this.#isDescendant(m.target)) || + (m.type === 'childList' && [...m.addedNodes, ...m.removedNodes].some(this.#isDescendant)) + ) + ) { + this.#updateRefs(); + } + }); + + /** + * Checks if a given node is a descendant of this component. + * + * @param {Node} node - The node to check. + * @returns {boolean} True if the node is a descendant of this component. + */ + #isDescendant = (node) => getClosestComponent(getAncestor(node)) === this; +} + +/** + * Get the ancestor of a given node. + * + * @param {Node} node - The node to get the ancestor of. + * @returns {Node | null} The ancestor of the node or null if none is found. + */ +function getAncestor(node) { + if (node.parentNode) return node.parentNode; + + const root = node.getRootNode(); + if (root instanceof ShadowRoot) return root.host; + + return null; +} + +/** + * Recursively finds the closest ancestor that is an instance of `Component`. + * + * @param {Node | null} node - The starting node to search from. + * @returns {HTMLElement | null} The closest ancestor `Component` instance or null if none is found. + */ +function getClosestComponent(node) { + if (!node) return null; + if (node instanceof Component) return node; + if (node instanceof HTMLElement && node.tagName.toLowerCase().endsWith('-component')) return node; + + const ancestor = getAncestor(node); + if (ancestor) return getClosestComponent(ancestor); + + return null; +} + +/** + * Initializes the event listeners for custom event handling. + * + * Sets up event listeners for specified events and delegates the handling of those events + * to methods defined on the closest `Component` instance, based on custom attributes. + */ +let initialized = false; + +function registerEventListeners() { + if (initialized) return; + initialized = true; + + const events = ['click', 'change', 'select', 'focus', 'blur', 'submit', 'input', 'keydown', 'keyup', 'toggle']; + const shouldBubble = ['focus', 'blur']; + const expensiveEvents = ['pointerenter', 'pointerleave']; + + for (const eventName of [...events, ...expensiveEvents]) { + const attribute = `on:${eventName}`; + + document.addEventListener( + eventName, + (event) => { + const element = getElement(event); + + if (!element) return; + + const proxiedEvent = + event.target !== element + ? new Proxy(event, { + get(target, property) { + if (property === 'target') return element; + + const value = Reflect.get(target, property); + + if (typeof value === 'function') { + return value.bind(target); + } + + return value; + }, + }) + : event; + + const value = element.getAttribute(attribute) ?? ''; + let [selector, method] = value.split('/'); + // Extract the last segment of the attribute value delimited by `?` or `/` + // Do not use lookback for Safari 16.0 compatibility + const matches = value.match(/([\/\?][^\/\?]+)([\/\?][^\/\?]+)$/); + const data = matches ? matches[2] : null; + const instance = selector + ? selector.startsWith('#') + ? document.querySelector(selector) + : element.closest(selector) + : getClosestComponent(element); + + if (!(instance instanceof Component) || !method) return; + + method = method.replace(/\?.*/, ''); + + const callback = /** @type {any} */ (instance)[method]; + + if (typeof callback === 'function') { + try { + /** @type {(Event | Data)[]} */ + const args = [proxiedEvent]; + + if (data) args.unshift(parseData(data)); + + callback.call(instance, ...args); + } catch (error) { + console.error(error); + } + } + }, + { capture: true } + ); + } + + /** @param {Event} event */ + function getElement(event) { + const target = event.composedPath?.()[0] ?? event.target; + + if (!(target instanceof Element)) return; + + if (target.hasAttribute(`on:${event.type}`)) { + return target; + } + + if (expensiveEvents.includes(event.type)) { + return null; + } + + return event.bubbles || shouldBubble.includes(event.type) ? target.closest(`[on\\:${event.type}]`) : null; + } +} + +/** + * Parses a string to extract data based on a delimiter. + * + * @param {string} str - The string to parse. + * @returns {Object|Array|string} The parsed data. + */ +function parseData(str) { + const delimiter = str[0]; + const data = str.slice(1); + + return delimiter === '?' + ? Object.fromEntries( + Array.from(new URLSearchParams(data).entries()).map(([key, value]) => [key, parseValue(value)]) + ) + : parseValue(data); +} + +/** + * @typedef {Object|Array|string} Data + */ + +/** + * Parses a string value to its appropriate type. + * + * @param {string} str - The string to parse. + * @returns {Data} The parsed value. + */ +function parseValue(str) { + if (str === 'true') return true; + if (str === 'false') return false; + + const maybeNumber = Number(str); + if (!isNaN(maybeNumber) && str.trim() !== '') return maybeNumber; + + return str; +} + +/** + * Throws a formatted error when a required ref is not found in the component. + */ +class MissingRefError extends Error { + /** + * @param {string} ref + * @param {Component} component + */ + constructor(ref, component) { + super(`Required ref "${ref}" not found in component ${component.tagName.toLowerCase()}`); + } +} diff --git a/assets/copy-to-clipboard.js b/assets/copy-to-clipboard.js new file mode 100644 index 000000000..99693ec8b --- /dev/null +++ b/assets/copy-to-clipboard.js @@ -0,0 +1,26 @@ +import { Component } from '@theme/component'; + +/** + * Handles copying text to clipboard, from an event like a click. + * Optionally, reveals a success message after copying. + * @extends {Component} + */ +class CopyToClipboardComponent extends Component { + copyToClipboard() { + const copyContent = this.getAttribute('text-to-copy'); + + if (!copyContent) return; + + navigator.clipboard.writeText(copyContent); + + const copySuccessMessage = this.refs.copySuccessMessage; + + if (copySuccessMessage instanceof Element) { + copySuccessMessage.classList.remove('visually-hidden'); + } + } +} + +if (!customElements.get('copy-to-clipboard-component')) { + customElements.define('copy-to-clipboard-component', CopyToClipboardComponent); +} diff --git a/assets/dialog.js b/assets/dialog.js new file mode 100644 index 000000000..ce3c67968 --- /dev/null +++ b/assets/dialog.js @@ -0,0 +1,193 @@ +import { Component } from '@theme/component'; +import { debounce, isClickedOutside, onAnimationEnd } from '@theme/utilities'; + +/** + * A custom element that manages a dialog. + * + * @typedef {object} Refs + * @property {HTMLDialogElement} dialog – The dialog element. + * + * @extends Component + */ +export class DialogComponent extends Component { + requiredRefs = ['dialog']; + + connectedCallback() { + super.connectedCallback(); + + if (this.minWidth || this.maxWidth) { + window.addEventListener('resize', this.#handleResize); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.minWidth || this.maxWidth) { + window.removeEventListener('resize', this.#handleResize); + } + } + + #handleResize = debounce(() => { + const { minWidth, maxWidth } = this; + + if (!minWidth && !maxWidth) return; + + const windowWidth = window.innerWidth; + if (windowWidth < minWidth || windowWidth > maxWidth) { + this.closeDialog(); + } + }, 50); + + #previousScrollY = 0; + + /** + * Shows the dialog. + */ + showDialog() { + const { dialog } = this.refs; + + if (dialog.open) return; + + const scrollY = window.scrollY; + this.#previousScrollY = scrollY; + + // Prevent layout thrashing by separating DOM reads from DOM writes + requestAnimationFrame(() => { + document.body.style.width = '100%'; + document.body.style.position = 'fixed'; + document.body.style.top = `-${scrollY}px`; + + dialog.showModal(); + this.dispatchEvent(new DialogOpenEvent()); + + this.addEventListener('click', this.#handleClick); + this.addEventListener('keydown', this.#handleKeyDown); + }); + } + + /** + * Closes the dialog. + */ + closeDialog = async () => { + const { dialog } = this.refs; + + if (!dialog.open) return; + + this.removeEventListener('click', this.#handleClick); + this.removeEventListener('keydown', this.#handleKeyDown); + + // Force browser to restart animation by resetting it + // Temporarily remove any existing animation state + dialog.style.animation = 'none'; + + // Force a reflow + void dialog.offsetWidth; + + // Now add the closing class and restore animation + dialog.classList.add('dialog-closing'); + dialog.style.animation = ''; + + await onAnimationEnd(dialog, undefined, { + subtree: false, + }); + + document.body.style.width = ''; + document.body.style.position = ''; + document.body.style.top = ''; + window.scrollTo({ top: this.#previousScrollY, behavior: 'instant' }); + + dialog.close(); + dialog.classList.remove('dialog-closing'); + + this.dispatchEvent(new DialogCloseEvent()); + }; + + /** + * Toggles the dialog. + */ + toggleDialog = () => { + if (this.refs.dialog.open) { + this.closeDialog(); + } else { + this.showDialog(); + } + }; + + /** + * Closes the dialog when the user clicks outside of it. + * + * @param {MouseEvent} event - The mouse event. + */ + #handleClick(event) { + const { dialog } = this.refs; + + if (isClickedOutside(event, dialog)) { + this.closeDialog(); + } + } + + /** + * Closes the dialog when the user presses the escape key. + * + * @param {KeyboardEvent} event - The keyboard event. + */ + #handleKeyDown(event) { + if (event.key !== 'Escape') return; + + event.preventDefault(); + this.closeDialog(); + } + + /** + * Gets the minimum width of the dialog. + * + * @returns {number} The minimum width of the dialog. + */ + get minWidth() { + return Number(this.getAttribute('dialog-active-min-width')); + } + + /** + * Gets the maximum width of the dialog. + * + * @returns {number} The maximum width of the dialog. + */ + get maxWidth() { + return Number(this.getAttribute('dialog-active-max-width')); + } +} + +if (!customElements.get('dialog-component')) customElements.define('dialog-component', DialogComponent); + +export class DialogOpenEvent extends CustomEvent { + constructor() { + super(DialogOpenEvent.eventName); + } + + static eventName = 'dialog:open'; +} + +export class DialogCloseEvent extends CustomEvent { + constructor() { + super(DialogCloseEvent.eventName); + } + + static eventName = 'dialog:close'; +} + +document.addEventListener( + 'toggle', + (event) => { + if (event.target instanceof HTMLDetailsElement) { + if (event.target.hasAttribute('scroll-lock')) { + const { open } = event.target; + if (open) { + document.documentElement.setAttribute('scroll-lock', ''); + } else { + document.documentElement.removeAttribute('scroll-lock'); + } + } + } + }, + { capture: true } +); diff --git a/assets/drag-zoom-wrapper.js b/assets/drag-zoom-wrapper.js new file mode 100644 index 000000000..77eb0889f --- /dev/null +++ b/assets/drag-zoom-wrapper.js @@ -0,0 +1,503 @@ +import { DialogCloseEvent } from './dialog.js'; +import { clamp, preventDefault, isMobileBreakpoint } from './utilities.js'; +import { Component } from '@theme/component'; + +const MIN_ZOOM = 1; +const MAX_ZOOM = 5; +const DEFAULT_ZOOM = 1.5; +const DOUBLE_TAP_DELAY = 300; +const DOUBLE_TAP_DISTANCE = 50; +const DRAG_THRESHOLD = 10; + +export class DragZoomWrapper extends Component { + #controller = new AbortController(); + /** @type {number} */ + #scale = DEFAULT_ZOOM; + /** @type {number} */ + #initialDistance = 0; + /** @type {number} */ + #startScale = DEFAULT_ZOOM; + /** @type {Point} */ + #translate = { x: 0, y: 0 }; + /** @type {Point} */ + #startPosition = { x: 0, y: 0 }; + /** @type {Point} */ + #startTranslate = { x: 0, y: 0 }; + /** @type {boolean} */ + #isDragging = false; + /** @type {boolean} */ + #initialized = false; + /** @type {number | null} */ + #animationFrame = null; + /** @type {number} */ + #lastTapTime = 0; + /** @type {Point | null} */ + #lastTapPosition = null; + + /** @type {boolean} */ + #hasDraggedBeyondThreshold = false; + + /** @type {boolean} */ + #hasManualZoom = false; + + get #image() { + return this.querySelector('img'); + } + + connectedCallback() { + super.connectedCallback(); + if (!this.#image) return; + + this.#initResizeListener(); + window.addEventListener(DialogCloseEvent.eventName, this.#resetZoom); + + if (!isMobileBreakpoint()) return; + + this.#initEventListeners(); + this.#updateTransform(); + } + + #initResizeListener() { + this.#resizeObserver.observe(this); + } + + #initEventListeners() { + if (this.#initialized) return; + this.#initialized = true; + const { signal } = this.#controller; + const options = { passive: false, signal }; + + this.addEventListener('touchstart', this.#handleTouchStart, options); + this.addEventListener('touchmove', this.#handleTouchMove, options); + this.addEventListener('touchend', this.#handleTouchEnd, options); + + // Initialize transform immediately + this.#updateTransform(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener(DialogCloseEvent.eventName, this.#resetZoom); + this.#controller.abort(); + this.#resizeObserver.disconnect(); + this.#cancelAnimationFrame(); + } + + #handleResize = () => { + if (!this.#initialized && isMobileBreakpoint()) { + this.#initEventListeners(); + } + + if (this.#initialized) { + this.#requestUpdateTransform(); + } + }; + + #resizeObserver = new ResizeObserver(this.#handleResize); + + /** + * @param {TouchEvent} event + */ + #handleTouchStart = (event) => { + preventDefault(event); + + const touchCount = event.touches.length; + + if (touchCount === 2) { + // Early exit if touches are invalid + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + if (!touch1 || !touch2) return; + + // Avoid object allocation by passing touches directly + this.#startZoomGestureFromTouches(touch1, touch2); + } else if (touchCount === 1) { + const touch = event.touches[0]; + if (!touch) return; + + // Use performance.now() for better precision and performance + const currentTime = performance.now(); + const timeSinceLastTap = currentTime - this.#lastTapTime; + + // Early exit if too much time has passed + if (timeSinceLastTap >= DOUBLE_TAP_DELAY) { + this.#storeTapInfo(currentTime, touch); + this.#startDragGestureFromTouch(touch); + return; + } + + // Only check distance if we have a previous tap within time window + if (this.#lastTapPosition) { + // Distance calculation with early exit + const distance = getDistance(touch, this.#lastTapPosition); + + if (distance < DOUBLE_TAP_DISTANCE) { + // This is a double-tap, handle zoom toggle + this.#handleDoubleTapFromTouch(touch); + this.#lastTapTime = 0; // Reset to prevent triple-tap + this.#lastTapPosition = null; + return; + } + } + + // Store tap info for potential double-tap detection + this.#storeTapInfo(currentTime, touch); + this.#startDragGestureFromTouch(touch); + } + }; + + /** + * Start a zoom gesture with two touches + * @param {Touch} touch1 + * @param {Touch} touch2 + */ + #startZoomGestureFromTouches(touch1, touch2) { + // Calculate initial distance between touches + this.#initialDistance = getDistance(touch1, touch2); + this.#startScale = this.#scale; + this.#isDragging = false; + } + + /** + * Start a drag gesture with a single touch + * @param {Touch} touch + */ + #startDragGestureFromTouch(touch) { + this.#startPosition = { x: touch.clientX, y: touch.clientY }; + this.#startTranslate = { x: this.#translate.x, y: this.#translate.y }; + this.#isDragging = true; + this.#hasDraggedBeyondThreshold = false; + } + + /** + * Store tap information for double-tap detection + * @param {number} currentTime + * @param {Touch} touch + */ + #storeTapInfo(currentTime, touch) { + this.#lastTapTime = currentTime; + this.#lastTapPosition = { x: touch.clientX, y: touch.clientY }; + } + + /** + * Handle double-tap zoom toggle from touch + * @param {Touch} touch - The touch where the double-tap occurred + */ + #handleDoubleTapFromTouch(touch) { + const containerCenter = { + x: this.clientWidth / 2, + y: this.clientHeight / 2, + }; + + let targetZoom; + + // If manual zoom has been used, reset to 1x + if (this.#hasManualZoom) { + targetZoom = MIN_ZOOM; // 1x + this.#hasManualZoom = false; // Reset the flag + this.#translate = { x: 0, y: 0 }; // Center the image + } else { + // Toggle between zoom levels: 1x ↔ 1.5x + const tolerance = 0.05; // Small tolerance for floating point comparison + + if (Math.abs(this.#scale - MIN_ZOOM) < tolerance) { + // Currently at 1x, go to 1.5x + targetZoom = DEFAULT_ZOOM; + } else { + // Currently at 1.5x or any other level, go to 1x + targetZoom = MIN_ZOOM; + } + } + + // If we're not going to 1x, adjust translation to center zoom on the tap point + if (targetZoom !== MIN_ZOOM) { + const oldScale = this.#scale; + this.#scale = Math.min(MAX_ZOOM, targetZoom); + + // Calculate the distance from tap point to container center + const distanceFromCenter = { + x: touch.clientX - containerCenter.x, + y: touch.clientY - containerCenter.y, + }; + + // Adjust translation to center zoom on the tap point + const scaleDelta = this.#scale / oldScale - 1.0; + this.#translate.x -= (distanceFromCenter.x * scaleDelta) / this.#scale; + this.#translate.y -= (distanceFromCenter.y * scaleDelta) / this.#scale; + } else { + // Going to 1x, set the scale and center the image + this.#scale = targetZoom; + this.#translate = { x: 0, y: 0 }; // Center the image when going to 1x + } + + this.#requestUpdateTransform(); + } + + /** + * @param {TouchEvent} event + */ + #handleTouchMove = (event) => { + preventDefault(event); + + const touchCount = event.touches.length; + + if (touchCount === 2) { + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + if (touch1 && touch2) { + this.#processZoomGesture(touch1, touch2); + } + } else if (touchCount === 1 && this.#isDragging) { + const touch = event.touches[0]; + if (touch) { + this.#processDragGesture(touch); + } + } + }; + + /** + * Process zoom gesture from touches + * @param {Touch} touch1 + * @param {Touch} touch2 + */ + #processZoomGesture(touch1, touch2) { + // Calculate midpoint directly without object allocation + const midX = (touch1.clientX + touch2.clientX) / 2; + const midY = (touch1.clientY + touch2.clientY) / 2; + + // Calculate current distance between touches + const currentDistance = getDistance(touch1, touch2); + + const oldScale = this.#scale; + + // Calculate and apply new scale + const newScale = (currentDistance / this.#initialDistance) * this.#startScale; + this.#scale = clamp(newScale, MIN_ZOOM, MAX_ZOOM); + + // Mark that manual zoom has been used + this.#hasManualZoom = true; + + // Adjust translation to keep the pinch midpoint stationary + const containerCenterX = this.clientWidth / 2; + const containerCenterY = this.clientHeight / 2; + + const distanceFromCenterX = midX - containerCenterX; + const distanceFromCenterY = midY - containerCenterY; + + // Calculate how the image position needs to change to keep the midpoint stationary + const scaleDelta = this.#scale / oldScale - 1.0; + + // Apply correction to prevent zooming on the opposite side of the midpoint + this.#translate.x -= (distanceFromCenterX * scaleDelta) / this.#scale; + this.#translate.y -= (distanceFromCenterY * scaleDelta) / this.#scale; + + this.#requestUpdateTransform(); + this.#isDragging = false; + } + + /** + * Process drag gesture from touch + * @param {Touch} touch + */ + #processDragGesture(touch) { + // Check if we've moved beyond the drag threshold + const distance = getDistance(touch, this.#startPosition); + + if (!this.#hasDraggedBeyondThreshold && distance < DRAG_THRESHOLD) { + // Movement is too small, don't process as drag yet + return; + } + + this.#hasDraggedBeyondThreshold = true; + + // Calculate movement deltas for translation + const dx = touch.clientX - this.#startPosition.x; + const dy = touch.clientY - this.#startPosition.y; + + // Calculate new translation directly + this.#translate.x = this.#startTranslate.x + dx / this.#scale; + this.#translate.y = this.#startTranslate.y + dy / this.#scale; + + this.#requestUpdateTransform(); + } + + /** + * @param {TouchEvent} event + */ + #handleTouchEnd = (event) => { + if (event.touches.length === 0) { + this.#isDragging = false; + this.#requestUpdateTransform(); + + this.#hasDraggedBeyondThreshold = false; + } + }; + + /** + * Constrain image translation to keep it within the viewport + */ + #constrainTranslation() { + const containerWidth = this.clientWidth; + const containerHeight = this.clientHeight; + if (!containerWidth || !containerHeight || !this.#image) return; + + // Keep scale between MIN_ZOOM (1) and MAX_ZOOM (5) + this.#scale = clamp(this.#scale, MIN_ZOOM, MAX_ZOOM); + + // At minimum zoom (1x), the full image should be visible with no dragging allowed + if (this.#scale <= MIN_ZOOM) { + this.#translate.x = 0; + this.#translate.y = 0; + return; + } + + // Get wrapper dimensions + const wrapperRect = this.getBoundingClientRect(); + + // Calculate ACTUAL image content dimensions at current zoom + // The image element may fill the wrapper, but the content has its own aspect ratio + const imageElement = this.#image; + let naturalWidth, naturalHeight; + + // Try to get natural dimensions + if (imageElement.naturalWidth > 0 && imageElement.naturalHeight > 0) { + naturalWidth = imageElement.naturalWidth; + naturalHeight = imageElement.naturalHeight; + } else { + // Fallback: assume square image if we can't get natural dimensions + naturalWidth = wrapperRect.width; + naturalHeight = wrapperRect.width; + } + + // Calculate how the image fits within the wrapper (object-fit: contain behavior) + const imageAspectRatio = naturalWidth / naturalHeight; + const wrapperAspectRatio = wrapperRect.width / wrapperRect.height; + + let actualImageWidth, actualImageHeight; + + if (imageAspectRatio > wrapperAspectRatio) { + // Image is wider - width fits exactly, height is smaller (letterboxed top/bottom) + actualImageWidth = wrapperRect.width; + actualImageHeight = actualImageWidth / imageAspectRatio; + } else { + // Image is taller - height fits exactly, width is smaller (letterboxed left/right) + actualImageHeight = wrapperRect.height; + actualImageWidth = actualImageHeight * imageAspectRatio; + } + + // Apply current zoom scale + const scaledImageWidth = actualImageWidth * this.#scale; + const scaledImageHeight = actualImageHeight * this.#scale; + + // SIMPLE APPROACH: Calculate constraints directly from image content dimensions + // If image content is larger than wrapper, calculate max translation directly + + const horizontalOverflow = Math.max(0, scaledImageWidth - wrapperRect.width); + const verticalOverflow = Math.max(0, scaledImageHeight - wrapperRect.height); + + // Max translation is half the overflow (since image starts centered) + const maxTranslateX = horizontalOverflow / 2 / this.#scale; + const maxTranslateY = verticalOverflow / 2 / this.#scale; + + // Apply symmetric constraints (object-fit: contain behavior) + // Image starts centered, can move maxTranslate in each direction + this.#translate.x = clamp(this.#translate.x, -maxTranslateX, maxTranslateX); + this.#translate.y = clamp(this.#translate.y, -maxTranslateY, maxTranslateY); + + // Apply final transforms to CSS + this.style.setProperty('--drag-zoom-scale', this.#scale.toString()); + this.style.setProperty('--drag-zoom-translate-x', `${this.#translate.x}px`); + this.style.setProperty('--drag-zoom-translate-y', `${this.#translate.y}px`); + } + + /** + * Request an animation frame to update the transform + */ + #requestUpdateTransform = () => { + if (!this.#animationFrame) { + this.#animationFrame = requestAnimationFrame(this.#updateTransform); + } + }; + + /** + * Cancel any pending animation frame + */ + #cancelAnimationFrame() { + if (this.#animationFrame) { + cancelAnimationFrame(this.#animationFrame); + this.#animationFrame = null; + } + } + + #updateTransform = () => { + this.#animationFrame = null; + + this.#constrainTranslation(); + this.style.setProperty('--drag-zoom-scale', this.#scale.toString()); + this.style.setProperty('--drag-zoom-translate-x', `${this.#translate.x}px`); + this.style.setProperty('--drag-zoom-translate-y', `${this.#translate.y}px`); + }; + + /** + * Reset zoom to default state (1.5x scale, centered position) + * Called when zoom is exited/closed + */ + #resetZoom = () => { + // Reset scale and translation to defaults + this.#scale = DEFAULT_ZOOM; + this.#startScale = DEFAULT_ZOOM; + this.#translate.x = 0; + this.#translate.y = 0; + + // Reset gesture state to prevent interference on next zoom open + this.#startPosition = { x: 0, y: 0 }; + this.#startTranslate = { x: 0, y: 0 }; + this.#isDragging = false; + this.#lastTapTime = 0; + this.#lastTapPosition = null; + this.#hasDraggedBeyondThreshold = false; + + // Update CSS properties to reflect reset state + this.style.setProperty('--drag-zoom-scale', DEFAULT_ZOOM.toString()); + this.style.setProperty('--drag-zoom-translate-x', '0px'); + this.style.setProperty('--drag-zoom-translate-y', '0px'); + }; + + destroy() { + this.#controller.abort(); + this.#cancelAnimationFrame(); + } +} + +/** + * Calculate distance between two points or touches + * @param {Point | Touch} point1 - First point or touch + * @param {Point | Touch} point2 - Second point or touch + * @returns {number} Distance between the points + */ +function getDistance(point1, point2) { + // Handle both Point objects (x, y) and Touch objects (clientX, clientY) + const x1 = /** @type {Point} */ (point1).x ?? /** @type {Touch} */ (point1).clientX; + const y1 = /** @type {Point} */ (point1).y ?? /** @type {Touch} */ (point1).clientY; + const x2 = /** @type {Point} */ (point2).x ?? /** @type {Touch} */ (point2).clientX; + const y2 = /** @type {Point} */ (point2).y ?? /** @type {Touch} */ (point2).clientY; + + const dx = x1 - x2; + const dy = y1 - y2; + return Math.sqrt(dx * dx + dy * dy); +} + +if (!customElements.get('drag-zoom-wrapper')) { + customElements.define('drag-zoom-wrapper', DragZoomWrapper); +} + +/** + * @typedef {Object} Point + * @property {number} x + * @property {number} y + */ + +/** + * @typedef {HTMLElement} ZoomDialogElement + * @property {Function} close - Method to close the zoom dialog + */ diff --git a/assets/events.js b/assets/events.js new file mode 100644 index 000000000..367f5616a --- /dev/null +++ b/assets/events.js @@ -0,0 +1,290 @@ +/** + * @namespace ThemeEvents + * @description A collection of theme-specific events that can be used to trigger and listen for changes anywhere in the theme. + * @example + * document.dispatchEvent(new VariantUpdateEvent(variant, sectionId, { html })); + * document.addEventListener(ThemeEvents.variantUpdate, (e) => { console.log(e.detail.variant) }); + */ +export class ThemeEvents { + /** @static @constant {string} Event triggered when a variant is selected */ + static variantSelected = 'variant:selected'; + /** @static @constant {string} Event triggered when a variant is changed */ + static variantUpdate = 'variant:update'; + /** @static @constant {string} Event triggered when the cart items or quantities are updated */ + static cartUpdate = 'cart:update'; + /** @static @constant {string} Event triggered when a cart update fails */ + static cartError = 'cart:error'; + /** @static @constant {string} Event triggered when a media (video, 3d model) is loaded */ + static mediaStartedPlaying = 'media:started-playing'; + // Event triggered when quantity-selector value is changed + static quantitySelectorUpdate = 'quantity-selector:update'; + /** @static @constant {string} Event triggered when a predictive search is expanded */ + static megaMenuHover = 'megaMenu:hover'; + /** @static @constant {string} Event triggered when a zoom dialog media is selected */ + static zoomMediaSelected = 'zoom-media:selected'; + /** @static @constant {string} Event triggered when a discount is applied */ + static discountUpdate = 'discount:update'; + /** @static @constant {string} Event triggered when changing collection filters */ + static FilterUpdate = 'filter:update'; +} + +/** + * Event fired when a variant is selected + * @extends {Event} + */ +export class VariantSelectedEvent extends Event { + /** + * Creates a new VariantSelectedEvent + * @param {Object} resource - The new variant object + * @param {string} resource.id - The id of the variant + */ + constructor(resource) { + super(ThemeEvents.variantSelected, { bubbles: true }); + this.detail = { + resource, + }; + } +} + +/** + * Event fired after a variant is updated + * @extends {Event} + */ +export class VariantUpdateEvent extends Event { + /** + * Creates a new VariantUpdateEvent + * @param {Object} resource - The new variant object + * @param {string} resource.id - The id of the variant + * @param {boolean} resource.available - Whether the variant is available + * @param {boolean} resource.inventory_management - Whether the variant has inventory management + * @param {string} [resource.sku] - The SKU of the variant + * @param {Object} [resource.featured_media] - The featured media of the variant + * @param {string} [resource.featured_media.id] - The id of the featured media + * @param {Object} [resource.featured_media.preview_image] - The preview image of the featured media + * @param {string} [resource.featured_media.preview_image.src] - The src URL of the preview image + * @param {string} sourceId - The id of the element the action was triggered from + * @param {Object} data - Additional event data + * @param {Document} data.html - The new document fragment for the variant + * @param {string} data.productId - The product ID of the updated variant, used to ensure the correct product form is updated + * @param {Object} [data.newProduct] - If a new product was loaded as part of the variant update (combined listing) + * @param {string} data.newProduct.id - The id of the new product + * @param {string} data.newProduct.url - The url of the new product + */ + constructor(resource, sourceId, data) { + super(ThemeEvents.variantUpdate, { bubbles: true }); + this.detail = { + resource: resource || null, + sourceId, + data: { + html: data.html, + productId: data.productId, + newProduct: data.newProduct, + }, + }; + } +} + +/** + * Event class for cart additions + * @extends {Event} + */ +export class CartAddEvent extends Event { + /** + * Creates a new CartAddEvent + * @param {Object} [resource] - The new cart object + * @param {string} [sourceId] - The id of the element the action was triggered from + * @param {Object} [data] - Additional event data + * @param {boolean} [data.didError] - Whether the cart operation failed + * @param {string} [data.source] - The source of the cart update + * @param {string} [data.productId] - The id of the product card that was updated + * @param {number} [data.itemCount] - The number of items in the cart + * @param {string} [data.variantId] - The id of the product variant that was added + * @param {Record} [data.sections] - The sections affected by the cart operation + */ + constructor(resource, sourceId, data) { + super(CartAddEvent.eventName, { bubbles: true }); + this.detail = { + resource, + sourceId, + data: { + ...data, + }, + }; + } + + static eventName = ThemeEvents.cartUpdate; +} + +/** + * Event class for cart updates + * @extends {Event} + */ +export class CartUpdateEvent extends Event { + /** + * Creates a new CartUpdateEvent + * @param {Object} resource - The new cart object + * @param {string} sourceId - The id of the element the action was triggered from + * @param {Object} [data] - Additional event data + * @param {boolean} [data.didError] - Whether the cart operation failed + * @param {string} [data.source] - The source of the cart update + * @param {string} [data.productId] - The id of the product card that was updated + * @param {number} [data.itemCount] - The number of items in the cart + * @param {string} [data.variantId] - The id of the product variant that was updated + * @param {Record} [data.sections] - The sections affected by the cart operation + */ + constructor(resource, sourceId, data) { + super(ThemeEvents.cartUpdate, { bubbles: true }); + this.detail = { + resource, + sourceId, + data: { + ...data, + }, + }; + } +} + +/** + * Event class for cart errors + * @extends {Event} + */ +export class CartErrorEvent extends Event { + /** + * Creates a new CartErrorEvent + * @param {string} sourceId - The id of the element the action was triggered from + * @param {string} message - A message from the server response + * @param {Object} description - Description from the server response + * @param {Object} errors - Errors from the server response + */ + constructor(sourceId, message, description, errors) { + super(ThemeEvents.cartError, { bubbles: true }); + this.detail = { + sourceId, + data: { + message, + errors, + description, + }, + }; + } +} + +/** + * Event class for quantity-selector updates + * @extends {Event} + */ +export class QuantitySelectorUpdateEvent extends Event { + /** + * Creates a new QuantitySelectorUpdateEvent + * @param {number} quantity - Quantity value + * @param {number} [cartLine] - The id of the updated cart line + */ + constructor(quantity, cartLine) { + super(ThemeEvents.quantitySelectorUpdate, { bubbles: true }); + this.detail = { + quantity, + cartLine, + }; + } +} + +/** + * Event class for quantity-selector updates + * @extends {Event} + */ +export class DiscountUpdateEvent extends Event { + /** + * Creates a new DiscountUpdateEvent + * @param {Object} resource - The new cart object + * @param {string} sourceId - The id of the element the action was triggered from + */ + constructor(resource, sourceId) { + super(ThemeEvents.discountUpdate, { bubbles: true }); + this.detail = { + resource, + sourceId, + }; + } +} + +/** + * Event class for media playback starts + * @extends {Event} + */ +export class MediaStartedPlayingEvent extends Event { + /** + * Creates a new MediaStartedPlayingEvent + * @param {HTMLElement} resource - The element containing the video that emitted the event + */ + constructor(resource) { + super(ThemeEvents.mediaStartedPlaying, { bubbles: true }); + this.detail = { + resource, + }; + } +} + +/** + * @typedef {Object} SlideshowSelectEventData + * @property {number} index + * @property {string | null} id + * @property {Element} slide + * @property {number} previousIndex + * @property {boolean} userInitiated + * @property {'select' | 'scroll' | 'drag'} trigger + */ + +export class SlideshowSelectEvent extends Event { + /** @param {SlideshowSelectEventData} data */ + constructor(data) { + super(SlideshowSelectEvent.eventName, { bubbles: true }); + this.detail = data; + } + + /** @type {SlideshowSelectEventData} */ + detail; + + static eventName = 'slideshow:select'; +} + +/** + * Event class for zoom dialog media selection + * @extends {Event} + */ +export class ZoomMediaSelectedEvent extends Event { + /** + * Creates a new ZoomMediaSelectedEvent + * @param {number} index - The index of the selected media + */ + constructor(index) { + super(ThemeEvents.zoomMediaSelected, { bubbles: true }); + this.detail = { + index, + }; + } +} + +/** + * Event class for mega menu hover being hovered over + * @extends {Event} + */ +export class MegaMenuHoverEvent extends Event { + constructor() { + super(ThemeEvents.megaMenuHover, { bubbles: true }); + } +} + +/** Event class for facet filtering updates */ +export class FilterUpdateEvent extends Event { + /** @param {URLSearchParams} queryParams */ + constructor(queryParams) { + super(ThemeEvents.FilterUpdate, { bubbles: true }); + this.detail = { + queryParams, + }; + } + + shouldShowClearAll() { + return [...this.detail.queryParams.entries()].filter(([key]) => key.startsWith('filter.')).length > 0; + } +} diff --git a/assets/facets.js b/assets/facets.js new file mode 100644 index 000000000..df3ace11b --- /dev/null +++ b/assets/facets.js @@ -0,0 +1,840 @@ +import { sectionRenderer } from '@theme/section-renderer'; +import { Component } from '@theme/component'; +import { FilterUpdateEvent, ThemeEvents } from '@theme/events'; +import { debounce, startViewTransition } from '@theme/utilities'; +import { convertMoneyToMinorUnits, formatMoney } from '@theme/money-formatting'; +/** + * Search query parameter. + * @type {string} + */ +const SEARCH_QUERY = 'q'; + +/** + * Handles the main facets form functionality + * + * @typedef {Object} FacetsFormRefs + * @property {HTMLFormElement} facetsForm - The main facets form element + * @property {HTMLElement | undefined} facetStatus - The facet status element + * + * @extends {Component} + */ +class FacetsFormComponent extends Component { + requiredRefs = ['facetsForm']; + + /** + * Creates URL parameters from form data + * @param {FormData} [formData] - Optional form data to use instead of the main form + * @returns {URLSearchParams} The processed URL parameters + */ + createURLParameters(formData = new FormData(this.refs.facetsForm)) { + let newParameters = new URLSearchParams(/** @type any */ (formData)); + + if (newParameters.get('filter.v.price.gte') === '') newParameters.delete('filter.v.price.gte'); + if (newParameters.get('filter.v.price.lte') === '') newParameters.delete('filter.v.price.lte'); + + newParameters.delete('page'); + + const searchQuery = this.#getSearchQuery(); + if (searchQuery) newParameters.set(SEARCH_QUERY, searchQuery); + + return newParameters; + } + + /** + * Gets the search query parameter from the current URL + * @returns {string} The search query + */ + #getSearchQuery() { + const url = new URL(window.location.href); + return url.searchParams.get(SEARCH_QUERY) ?? ''; + } + + get sectionId() { + const id = this.getAttribute('section-id'); + if (!id) throw new Error('Section ID is required'); + return id; + } + + /** + * Updates the URL hash with current filter parameters + */ + #updateURLHash() { + const url = new URL(window.location.href); + const urlParameters = this.createURLParameters(); + + url.search = ''; + for (const [param, value] of urlParameters.entries()) { + url.searchParams.append(param, value); + } + + history.pushState({ urlParameters: urlParameters.toString() }, '', url.toString()); + } + + /** + * Updates filters and renders the section + */ + updateFilters = () => { + this.#updateURLHash(); + this.dispatchEvent(new FilterUpdateEvent(this.createURLParameters())); + this.#updateSection(); + }; + + /** + * Updates the section + */ + #updateSection() { + const viewTransition = !this.closest('dialog'); + + if (viewTransition) { + startViewTransition(() => sectionRenderer.renderSection(this.sectionId), ['product-grid']); + } else { + sectionRenderer.renderSection(this.sectionId); + } + } + + /** + * Updates filters based on a provided URL + * @param {string} url - The URL to update filters with + */ + updateFiltersByURL(url) { + history.pushState('', '', url); + this.dispatchEvent(new FilterUpdateEvent(this.createURLParameters())); + this.#updateSection(); + } +} + +if (!customElements.get('facets-form-component')) { + customElements.define('facets-form-component', FacetsFormComponent); +} + +/** + * @typedef {Object} FacetInputsRefs + * @property {HTMLInputElement[]} facetInputs - The facet input elements + */ + +/** + * Handles individual facet input functionality + * @extends {Component} + */ +class FacetInputsComponent extends Component { + get sectionId() { + const id = this.closest('.shopify-section')?.id; + if (!id) throw new Error('FacetInputs component must be a child of a section'); + return id; + } + + /** + * Updates filters and the selected facet summary + */ + updateFilters() { + const facetsForm = this.closest('facets-form-component'); + + if (!(facetsForm instanceof FacetsFormComponent)) return; + + facetsForm.updateFilters(); + this.#updateSelectedFacetSummary(); + } + + /** + * Handles keydown events for the facets form + * @param {KeyboardEvent} event - The keydown event + */ + handleKeyDown(event) { + if (!(event.target instanceof HTMLElement)) return; + const closestInput = event.target.querySelector('input'); + + if (!(closestInput instanceof HTMLInputElement)) return; + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + closestInput.checked = !closestInput.checked; + this.updateFilters(); + } + } + + /** + * Handles mouseover events on facet labels + * @param {MouseEvent} event - The mouseover event + */ + prefetchPage = debounce((event) => { + if (!(event.target instanceof HTMLElement)) return; + + const form = this.closest('form'); + if (!form) return; + + const formData = new FormData(form); + const inputElement = event.target.querySelector('input'); + + if (!(inputElement instanceof HTMLInputElement)) return; + + if (!inputElement.checked) formData.append(inputElement.name, inputElement.value); + + const facetsForm = this.closest('facets-form-component'); + if (!(facetsForm instanceof FacetsFormComponent)) return; + + const urlParameters = facetsForm.createURLParameters(formData); + + const url = new URL(window.location.pathname, window.location.origin); + + for (const [key, value] of urlParameters) url.searchParams.append(key, value); + + if (inputElement.checked) url.searchParams.delete(inputElement.name, inputElement.value); + + sectionRenderer.getSectionHTML(this.sectionId, true, url); + }, 200); + + cancelPrefetchPage = () => this.prefetchPage.cancel(); + + /** + * Updates the selected facet summary + */ + #updateSelectedFacetSummary() { + if (!this.refs.facetInputs) return; + + const checkedInputElements = this.refs.facetInputs.filter((input) => input.checked); + const details = this.closest('details'); + const statusComponent = details?.querySelector('facet-status-component'); + + if (!(statusComponent instanceof FacetStatusComponent)) return; + + statusComponent.updateListSummary(checkedInputElements); + } +} + +if (!customElements.get('facet-inputs-component')) { + customElements.define('facet-inputs-component', FacetInputsComponent); +} + +/** + * @typedef {Object} PriceFacetRefs + * @property {HTMLInputElement} minInput - The minimum price input + * @property {HTMLInputElement} maxInput - The maximum price input + */ + +/** + * Handles price facet functionality + * @extends {Component} + */ +class PriceFacetComponent extends Component { + /** @type {string} */ + currency; + /** @type {string} */ + moneyFormat; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('keydown', this.#onKeyDown); + this.currency = this.dataset.currency ?? 'USD'; + this.moneyFormat = this.#extractMoneyPlaceholder(this.dataset.moneyFormat ?? '{{amount}}'); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('keydown', this.#onKeyDown); + } + + /** + * Extracts the placeholder from a money format string, removing currency symbols. + * @param {string} format - The money format (e.g., "${{amount}}", "{{amount}} USD") + * @returns {string} Just the placeholder (e.g., "{{amount}}") + */ + #extractMoneyPlaceholder(format) { + const match = format.match(/{{\s*\w+\s*}}/); + return match ? match[0] : '{{amount}}'; + } + + /** + * Handles keydown events to restrict input to valid characters + * @param {KeyboardEvent} event - The keydown event + */ + #onKeyDown = (event) => { + if (event.metaKey) return; + + const pattern = /[0-9]|\.|,|'| |Tab|Backspace|Enter|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Delete|Escape/; + if (!event.key.match(pattern)) event.preventDefault(); + }; + + /** + * Updates price filter and results + */ + updatePriceFilterAndResults() { + const { minInput, maxInput } = this.refs; + + this.#adjustToValidValues(minInput); + this.#adjustToValidValues(maxInput); + + const facetsForm = this.closest('facets-form-component'); + if (!(facetsForm instanceof FacetsFormComponent)) return; + + facetsForm.updateFilters(); + this.#setMinAndMaxValues(); + this.#updateSummary(); + } + + /** + * Parses a formatted money value into minor units + * displayValue can come from user input or API response + * @param {string} displayValue - The display value (e.g., "10.50" for USD, "9,50" for EUR, "1000" for JPY) + * @param {string} currency - The currency code + * @returns {number} The value in minor units + */ + #parseDisplayValue(displayValue, currency) { + return convertMoneyToMinorUnits(displayValue, currency) ?? 0; + } + + /** + * Adjusts input values to be within valid range + * @param {HTMLInputElement} input - The input element to adjust + */ + #adjustToValidValues(input) { + if (input.value.trim() === '') return; + + const { currency, moneyFormat } = this; + // Parse the user's input value using currency-aware parsing + const value = this.#parseDisplayValue(input.value, currency); + + // data-min and data-max now contain raw minor unit values (not formatted) + const min = this.#parseDisplayValue(input.getAttribute('data-min') ?? '0', currency); + const max = this.#parseDisplayValue(input.getAttribute('data-max') ?? '0', currency); + + if (value < min) { + input.value = formatMoney(min, moneyFormat, currency); + } else if (value > max) { + input.value = formatMoney(max, moneyFormat, currency); + } + } + + /** + * Sets min and max values for the inputs + */ + #setMinAndMaxValues() { + const { minInput, maxInput } = this.refs; + + if (maxInput.value) minInput.setAttribute('data-max', maxInput.value); + if (minInput.value) maxInput.setAttribute('data-min', minInput.value); + if (minInput.value === '') maxInput.setAttribute('data-min', '0'); + if (maxInput.value === '') minInput.setAttribute('data-max', maxInput.getAttribute('data-max') ?? ''); + } + + /** + * Updates the price summary + */ + #updateSummary() { + const { minInput, maxInput } = this.refs; + const details = this.closest('details'); + const statusComponent = details?.querySelector('facet-status-component'); + + if (!(statusComponent instanceof FacetStatusComponent)) return; + + statusComponent?.updatePriceSummary(minInput, maxInput); + } +} + +if (!customElements.get('price-facet-component')) { + customElements.define('price-facet-component', PriceFacetComponent); +} + +/** + * Handles clearing of facet filters + * @extends {Component} + */ +class FacetClearComponent extends Component { + requiredRefs = ['clearButton']; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('keyup', this.#handleKeyUp); + document.addEventListener(ThemeEvents.FilterUpdate, this.#handleFilterUpdate); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener(ThemeEvents.FilterUpdate, this.#handleFilterUpdate); + } + + /** + * Clears the filter + * @param {Event} event - The click event + */ + clearFilter(event) { + if (!(event.target instanceof HTMLElement)) return; + + if (event instanceof KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + } + + const container = event.target.closest('facet-inputs-component, price-facet-component'); + container?.querySelectorAll('[type="checkbox"]:checked, input').forEach((input) => { + if (input instanceof HTMLInputElement) { + input.checked = false; + input.value = ''; + } + }); + + const details = event.target.closest('details'); + const statusComponent = details?.querySelector('facet-status-component'); + + if (!(statusComponent instanceof FacetStatusComponent)) return; + + statusComponent.clearSummary(); + + const facetsForm = this.closest('facets-form-component'); + if (!(facetsForm instanceof FacetsFormComponent)) return; + + facetsForm.updateFilters(); + } + + /** + * Handles keyup events + * @param {KeyboardEvent} event - The keyup event + */ + #handleKeyUp = (event) => { + if (event.metaKey) return; + if (event.key === 'Enter') this.clearFilter(event); + }; + + /** + * Toggle clear button visibility when filters are applied. Happens before the + * Section Rendering Request resolves. + * + * @param {FilterUpdateEvent} event + */ + #handleFilterUpdate = (event) => { + const { clearButton } = this.refs; + if (clearButton instanceof Element) { + clearButton.classList.toggle('facets__clear--active', event.shouldShowClearAll()); + } + }; +} + +if (!customElements.get('facet-clear-component')) { + customElements.define('facet-clear-component', FacetClearComponent); +} + +/** + * @typedef {Object} FacetRemoveComponentRefs + * @property {HTMLInputElement | undefined} clearButton - The button to clear filters + */ + +/** + * Handles removal of individual facet filters + * @extends {Component} + */ +class FacetRemoveComponent extends Component { + connectedCallback() { + super.connectedCallback(); + document.addEventListener(ThemeEvents.FilterUpdate, this.#handleFilterUpdate); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener(ThemeEvents.FilterUpdate, this.#handleFilterUpdate); + } + + /** + * Removes the filter + * @param {Object} data - The data object + * @param {string} data.form - The form to remove the filter from + * @param {Event} event - The click event + */ + removeFilter({ form }, event) { + if (event instanceof KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + } + + const url = this.dataset.url; + if (!url) return; + + const facetsForm = form ? document.getElementById(form) : this.closest('facets-form-component'); + + if (!(facetsForm instanceof FacetsFormComponent)) return; + + facetsForm.updateFiltersByURL(url); + } + + /** + * Toggle clear button visibility when filters are applied. Happens before the + * Section Rendering Request resolves. + * + * @param {FilterUpdateEvent} event + */ + #handleFilterUpdate = (event) => { + const { clearButton } = this.refs; + if (clearButton instanceof Element) { + const activeClass = this.getAttribute('active-class') || 'active'; + clearButton.classList.toggle(activeClass, event.shouldShowClearAll()); + } + }; +} + +if (!customElements.get('facet-remove-component')) { + customElements.define('facet-remove-component', FacetRemoveComponent); +} + +/** + * Handles sorting filter functionality + * + * @typedef {Object} SortingFilterRefs + * @property {HTMLDetailsElement} details - The details element + * @property {HTMLElement} summary - The summary element + * @property {HTMLElement} listbox - The listbox element + * + * @extends {Component} + */ +class SortingFilterComponent extends Component { + requiredRefs = ['details', 'summary', 'listbox']; + + /** + * Handles keyboard navigation in the sorting dropdown + * @param {KeyboardEvent} event - The keyboard event + */ + handleKeyDown = (event) => { + const { listbox } = this.refs; + if (!(listbox instanceof Element)) return; + + const options = Array.from(listbox.querySelectorAll('[role="option"]')); + const currentFocused = options.find((option) => option instanceof HTMLElement && option.tabIndex === 0); + let newFocusIndex = currentFocused ? options.indexOf(currentFocused) : 0; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + newFocusIndex = Math.min(newFocusIndex + 1, options.length - 1); + this.#moveFocus(options, newFocusIndex); + break; + + case 'ArrowUp': + event.preventDefault(); + newFocusIndex = Math.max(newFocusIndex - 1, 0); + this.#moveFocus(options, newFocusIndex); + break; + + case 'Enter': + case ' ': + if (event.target instanceof Element) { + const targetOption = event.target.closest('[role="option"]'); + if (targetOption) { + event.preventDefault(); + this.#selectOption(targetOption); + } + } + break; + + case 'Escape': + event.preventDefault(); + this.#closeDropdown(); + break; + } + }; + + /** + * Handles details toggle event + */ + handleToggle = () => { + const { details, summary, listbox } = this.refs; + if (!(details instanceof HTMLDetailsElement) || !(summary instanceof HTMLElement)) return; + + const isOpen = details.open; + summary.setAttribute('aria-expanded', isOpen.toString()); + + if (isOpen && listbox instanceof Element) { + // Move focus to selected option when dropdown opens + const selectedOption = listbox.querySelector('[aria-selected="true"]'); + if (selectedOption instanceof HTMLElement) { + selectedOption.focus(); + } + } + }; + + /** + * Moves focus between options + * @param {Element[]} options - The option elements + * @param {number} newIndex - The index of the option to focus + */ + #moveFocus(options, newIndex) { + // Remove tabindex from all options + options.forEach((option) => { + if (option instanceof HTMLElement) { + option.tabIndex = -1; + } + }); + + // Set tabindex and focus on new option + const targetOption = options[newIndex]; + if (targetOption instanceof HTMLElement) { + targetOption.tabIndex = 0; + targetOption.focus(); + } + } + + /** + * Selects an option and triggers form submission + * @param {Element} option - The option element to select + */ + #selectOption(option) { + const input = option.querySelector('input[type="radio"]'); + if (input instanceof HTMLInputElement && option instanceof HTMLElement) { + // Update aria-selected states + this.querySelectorAll('[role="option"]').forEach((opt) => { + opt.setAttribute('aria-selected', 'false'); + }); + option.setAttribute('aria-selected', 'true'); + + // Trigger click on the input to ensure normal form behavior + input.click(); + + // Close dropdown and return focus (handles tabIndex reset) + this.#closeDropdown(); + } + } + + /** + * Closes the dropdown and returns focus to summary + */ + #closeDropdown() { + const { details, summary } = this.refs; + if (details instanceof HTMLDetailsElement) { + // Reset focus to match the actual selected option + const options = this.querySelectorAll('[role="option"]'); + const selectedOption = this.querySelector('[aria-selected="true"]'); + + options.forEach((opt) => { + if (opt instanceof HTMLElement) { + opt.tabIndex = -1; + } + }); + + if (selectedOption instanceof HTMLElement) { + selectedOption.tabIndex = 0; + } + + details.open = false; + if (summary instanceof HTMLElement) { + summary.focus(); + } + } + } + + /** + * Updates filter and sorting + * @param {Event} event - The change event + */ + updateFilterAndSorting(event) { + const facetsForm = + this.closest('facets-form-component') || this.closest('.shopify-section')?.querySelector('facets-form-component'); + + if (!(facetsForm instanceof FacetsFormComponent)) return; + const isMobile = window.innerWidth < 750; + + const shouldDisable = this.dataset.shouldUseSelectOnMobile === 'true'; + + // Because we have a select element on mobile and a bunch of radio buttons on desktop, + // we need to disable the input during "form-submission" to prevent duplicate entries. + if (shouldDisable) { + if (isMobile) { + const inputs = this.querySelectorAll('input[name="sort_by"]'); + inputs.forEach((input) => { + if (!(input instanceof HTMLInputElement)) return; + input.disabled = true; + }); + } else { + const selectElement = this.querySelector('select[name="sort_by"]'); + if (!(selectElement instanceof HTMLSelectElement)) return; + selectElement.disabled = true; + } + } + + facetsForm.updateFilters(); + this.updateFacetStatus(event); + + // Re-enable the input after the form-submission + if (shouldDisable) { + if (isMobile) { + const inputs = this.querySelectorAll('input[name="sort_by"]'); + inputs.forEach((input) => { + if (!(input instanceof HTMLInputElement)) return; + input.disabled = false; + }); + } else { + const selectElement = this.querySelector('select[name="sort_by"]'); + if (!(selectElement instanceof HTMLSelectElement)) return; + selectElement.disabled = false; + } + } + + // Close the details element when a value is selected + const { details } = this.refs; + if (!(details instanceof HTMLDetailsElement)) return; + details.open = false; + } + + /** + * Updates the facet status + * @param {Event} event - The change event + */ + updateFacetStatus(event) { + if (!(event.target instanceof HTMLSelectElement)) return; + + const details = this.querySelector('details'); + if (!details) return; + + const facetStatus = details.querySelector('facet-status-component'); + if (!(facetStatus instanceof FacetStatusComponent)) return; + + facetStatus.textContent = + event.target.value !== details.dataset.defaultSortBy ? event.target.dataset.optionName ?? '' : ''; + } +} + +if (!customElements.get('sorting-filter-component')) { + customElements.define('sorting-filter-component', SortingFilterComponent); +} + +/** + * @typedef {Object} FacetStatusRefs + * @property {HTMLElement} facetStatus - The facet status element + */ + +/** + * Handles facet status display + * @extends {Component} + */ +class FacetStatusComponent extends Component { + /** + * Updates the list summary + * @param {HTMLInputElement[]} checkedInputElements - The checked input elements + */ + updateListSummary(checkedInputElements) { + const checkedInputElementsCount = checkedInputElements.length; + + this.getAttribute('facet-type') === 'swatches' + ? this.#updateSwatchSummary(checkedInputElements, checkedInputElementsCount) + : this.#updateBubbleSummary(checkedInputElements, checkedInputElementsCount); + } + + /** + * Updates the swatch summary + * @param {HTMLInputElement[]} checkedInputElements - The checked input elements + * @param {number} checkedInputElementsCount - The number of checked inputs + */ + #updateSwatchSummary(checkedInputElements, checkedInputElementsCount) { + const { facetStatus } = this.refs; + facetStatus.classList.remove('bubble', 'facets__bubble'); + + if (checkedInputElementsCount === 0) { + facetStatus.innerHTML = ''; + return; + } + + if (checkedInputElementsCount > 3) { + facetStatus.innerHTML = checkedInputElementsCount.toString(); + facetStatus.classList.add('bubble', 'facets__bubble'); + return; + } + + facetStatus.innerHTML = Array.from(checkedInputElements) + .map((inputElement) => { + const swatch = inputElement.parentElement?.querySelector('span.swatch'); + return swatch?.outerHTML ?? ''; + }) + .join(''); + } + + /** + * Updates the bubble summary + * @param {HTMLInputElement[]} checkedInputElements - The checked input elements + * @param {number} checkedInputElementsCount - The number of checked inputs + */ + #updateBubbleSummary(checkedInputElements, checkedInputElementsCount) { + const { facetStatus } = this.refs; + const filterStyle = this.dataset.filterStyle; + + facetStatus.classList.remove('bubble', 'facets__bubble'); + + if (checkedInputElementsCount === 0) { + facetStatus.innerHTML = ''; + return; + } + + if (filterStyle === 'horizontal' && checkedInputElementsCount === 1) { + facetStatus.innerHTML = checkedInputElements[0]?.dataset.label ?? ''; + return; + } + + facetStatus.innerHTML = checkedInputElementsCount.toString(); + facetStatus.classList.add('bubble', 'facets__bubble'); + } + + /** + * Updates the price summary + * @param {HTMLInputElement} minInput - The minimum price input + * @param {HTMLInputElement} maxInput - The maximum price input + */ + updatePriceSummary(minInput, maxInput) { + const minInputValue = minInput.value; + const maxInputValue = maxInput.value; + const { facetStatus } = this.refs; + + if (!minInputValue && !maxInputValue) { + facetStatus.innerHTML = ''; + return; + } + + const currency = facetStatus.dataset.currency || ''; + const minInputNum = this.#parseCents(minInputValue, '0', currency); + const maxInputNum = this.#parseCents(maxInputValue, facetStatus.dataset.rangeMax, currency); + facetStatus.innerHTML = `${this.#formatMoney(minInputNum)}–${this.#formatMoney(maxInputNum)}`; + } + + /** + * Parses a decimal number as minor units (cents for most currencies, but adjusted for zero-decimal currencies) + * @param {string} value - The stringified decimal number to parse + * @param {string} fallback - The fallback value in case `value` is invalid (formatted string like "11,400") + * @param {string} currency - The currency code (e.g., 'USD', 'JPY', 'KRW') + * @returns {number} The money value in minor units + */ + #parseCents(value, fallback = '0', currency = '') { + // Try to parse the value + const result = convertMoneyToMinorUnits(value, currency); + if (result !== null) return result; + + // Fall back to parsing the fallback string (which may have formatting like "11,400") + const fallbackResult = convertMoneyToMinorUnits(fallback, currency); + if (fallbackResult !== null) return fallbackResult; + + // Last resort: clean and parse as integer + const cleanFallback = fallback.replace(/[^\d]/g, ''); + return parseInt(cleanFallback, 10) || 0; + } + + /** + * Formats money, replicated the implementation of the `money` liquid filters + * @param {number} moneyValue - The money value + * @returns {string} The formatted money value + */ + #formatMoney(moneyValue) { + if (!(this.refs.moneyFormat instanceof HTMLTemplateElement)) return ''; + + const format = this.refs.moneyFormat.content.textContent || '{{amount}}'; + const currency = this.refs.facetStatus.dataset.currency || ''; + + return formatMoney(moneyValue, format, currency); + } + + /** + * Clears the summary + */ + clearSummary() { + this.refs.facetStatus.innerHTML = ''; + } +} + +if (!customElements.get('facet-status-component')) { + customElements.define('facet-status-component', FacetStatusComponent); +} diff --git a/assets/floating-panel.js b/assets/floating-panel.js new file mode 100644 index 000000000..3ad1d5b17 --- /dev/null +++ b/assets/floating-panel.js @@ -0,0 +1,63 @@ +import { debounce, requestIdleCallback, viewTransition } from '@theme/utilities'; +import { Component } from '@theme/component'; + +const OFFSET = 40; + +/** + * A custom element that manages a floating panel. + */ +export class FloatingPanelComponent extends Component { + #updatePosition = async () => { + // Wait for any view transitions to finish + if (viewTransition.current) await viewTransition.current; + + const rect = this.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + + this.style.top = OFFSET + 'px'; + + if (rect.right > viewportWidth) { + const overflowAmount = rect.right - viewportWidth + OFFSET; + this.style.left = `-${overflowAmount}px`; + } + + if (rect.left < 0) { + const overflowAmount = Math.abs(rect.left) + OFFSET; + this.style.left = `${overflowAmount}px`; + } + + this.#mutationObserver.takeRecords(); + }; + + #mutationObserver = new MutationObserver(this.#updatePosition); + + #resizeListener = debounce(() => { + const parent = this.closest('details'); + const closeOnResize = this.dataset.closeOnResize === 'true'; + if (parent instanceof HTMLDetailsElement && closeOnResize) { + parent.open = false; + parent.removeAttribute('open'); + this.#updatePosition(); + } + }, 100); + + connectedCallback() { + super.connectedCallback(); + window.addEventListener('resize', this.#resizeListener); + + requestIdleCallback(() => { + this.#updatePosition(); + this.#mutationObserver.observe(this, { attributes: true }); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('resize', this.#resizeListener); + this.#mutationObserver.disconnect(); + } +} + +if (!customElements.get('floating-panel-component')) { + customElements.define('floating-panel-component', FloatingPanelComponent); +} diff --git a/assets/fly-to-cart.js b/assets/fly-to-cart.js new file mode 100644 index 000000000..459979a5b --- /dev/null +++ b/assets/fly-to-cart.js @@ -0,0 +1,80 @@ +import { yieldToMainThread } from '@theme/utilities'; +import { Component } from '@theme/component'; + +/** + * FlyToCart custom element for animating product images to cart + * This component creates a visual effect of a product "flying" to the cart when added + */ +class FlyToCart extends Component { + /** @type {Element} */ + source; + + /** @type {boolean} */ + useSourceSize = false; + + /** @type {Element} */ + destination; + + connectedCallback() { + super.connectedCallback(); + const intersectionObserver = new IntersectionObserver((entries) => { + /** @type {DOMRectReadOnly | null} */ + let sourceRect = null; + /** @type {DOMRectReadOnly | null} */ + let destinationRect = null; + + entries.forEach((entry) => { + if (entry.target === this.source) { + sourceRect = entry.boundingClientRect; + } else if (entry.target === this.destination) { + destinationRect = entry.boundingClientRect; + } + }); + + if (sourceRect && destinationRect) { + this.#animate(sourceRect, destinationRect); + } + + intersectionObserver.disconnect(); + }); + intersectionObserver.observe(this.source); + intersectionObserver.observe(this.destination); + } + + /** + * Animates the flying thingy along the bezier curve. + * @param {DOMRectReadOnly} sourceRect - The bounding client rect of the source. + * @param {DOMRectReadOnly} destinationRect - The bounding client rect of the destination. + */ + #animate = async (sourceRect, destinationRect) => { + //Define bezier curve points + const startPoint = { + x: sourceRect.left + sourceRect.width / 2, + y: sourceRect.top + sourceRect.height / 2, + }; + + const endPoint = { + x: destinationRect.left + destinationRect.width / 2, + y: destinationRect.top + destinationRect.height / 2, + }; + + // Position the flying thingy back to the start point + if (this.useSourceSize) { + this.style.setProperty('--width', `${sourceRect.width}px`); + this.style.setProperty('--height', `${sourceRect.height}px`); + } + this.style.setProperty('--start-x', `${startPoint.x}px`); + this.style.setProperty('--start-y', `${startPoint.y}px`); + this.style.setProperty('--travel-x', `${endPoint.x - startPoint.x}px`); + this.style.setProperty('--travel-y', `${endPoint.y - startPoint.y}px`); + + await yieldToMainThread(); + + await Promise.allSettled(this.getAnimations().map((a) => a.finished)); + this.remove(); + }; +} + +if (!customElements.get('fly-to-cart')) { + customElements.define('fly-to-cart', FlyToCart); +} diff --git a/assets/focus.js b/assets/focus.js new file mode 100644 index 000000000..68dbb2026 --- /dev/null +++ b/assets/focus.js @@ -0,0 +1,104 @@ +// Store references to our event handlers so we can remove them. +/** @type {Record void>} */ +const trapFocusHandlers = {}; + +/** + * Get all focusable elements within a container. + * @param {HTMLElement} container - The container to get focusable elements from. + * @returns {HTMLElement[]} An array of focusable elements. + */ +function getFocusableElements(container) { + return Array.from( + container.querySelectorAll( + "summary, a[href], button:enabled, [tabindex]:not([tabindex^='-']), [draggable], area, input:not([type=hidden]):enabled, select:enabled, textarea:enabled, object, iframe" + ) + ); +} + +/** + * Trap focus within the given container. + * @param {HTMLElement} container - The container to trap focus within. + */ +export function trapFocus(container) { + // Clean up any previously set traps. + removeTrapFocus(); + + // Gather focusable elements. + const focusable = getFocusableElements(container); + if (!focusable.length) { + // If nothing is focusable, just abort—no need to trap. + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + // Keydown handler for cycling focus with Tab and Shift+Tab + /** @type {(event: KeyboardEvent) => void} */ + trapFocusHandlers.keydown = (event) => { + if (event.key !== 'Tab') return; + + const activeEl = document.activeElement; + + // If on the last focusable and tabbing forward, go to first + if (!event.shiftKey && activeEl === last) { + event.preventDefault(); + first?.focus(); + } + // If on the first (or the container) and shift-tabbing, go to last + else if (event.shiftKey && (activeEl === first || activeEl === container)) { + event.preventDefault(); + last?.focus(); + } + }; + + // Focusin (capturing) handler to forcibly keep focus in the container + /** @type {(event: FocusEvent) => void} */ + trapFocusHandlers.focusin = (event) => { + // If the newly focused element isn't inside the container, redirect focus back. + if (event.target instanceof Node && !container.contains(event.target)) { + event.stopPropagation(); + // E.g., refocus the first focusable element: + first?.focus(); + } + }; + + // Attach the handlers + document.addEventListener('keydown', trapFocusHandlers.keydown, true); + // Use capture phase for focusin so we can catch it before it lands outside + document.addEventListener('focusin', trapFocusHandlers.focusin, true); + + // Finally, put focus where you want it. + container.focus(); +} + +/** + * Remove focus trap and optionally refocus another element. + */ +export function removeTrapFocus() { + trapFocusHandlers.keydown && document.removeEventListener('keydown', trapFocusHandlers.keydown, true); + trapFocusHandlers.focusin && document.removeEventListener('focusin', trapFocusHandlers.focusin, true); +} + +/** + * Cycle focus to the next or previous link + * + * @param {HTMLElement[]} items + * @param {number} increment + */ +export function cycleFocus(items, increment) { + const currentIndex = items.findIndex((item) => item.matches(':focus')); + let targetIndex = currentIndex + increment; + + if (targetIndex >= items.length) { + targetIndex = 0; + } else if (targetIndex < 0) { + targetIndex = items.length - 1; + } + + const targetItem = items[targetIndex]; + + if (!targetItem) return; + + targetItem.focus(); +} diff --git a/assets/gift-card-recipient-form.js b/assets/gift-card-recipient-form.js new file mode 100644 index 000000000..cf0b35354 --- /dev/null +++ b/assets/gift-card-recipient-form.js @@ -0,0 +1,414 @@ +import { Component } from '@theme/component'; +import { ThemeEvents, CartErrorEvent, CartAddEvent } from '@theme/events'; + +/** + * @typedef {Object} GiftCardRecipientFormRefs + * @property {HTMLInputElement} myEmailButton - Button for selecting my email option + * @property {HTMLInputElement} recipientEmailButton - Button for selecting recipient email option + * @property {HTMLDivElement} recipientFields - Container for recipient form fields + * @property {HTMLInputElement} recipientEmail - Recipient email input field + * @property {HTMLInputElement} recipientName - Recipient name input field + * @property {HTMLTextAreaElement} recipientMessage - Recipient message textarea + * @property {HTMLInputElement} recipientSendOn - Send on date input + * @property {HTMLInputElement} [timezoneOffset] - Timezone offset hidden input (optional) + * @property {HTMLInputElement} [controlFlag] - Shopify gift card control flag (optional as it's dynamically queried) + * @property {HTMLDivElement} [emailError] - Email error message container (optional) + * @property {HTMLDivElement} [nameError] - Name error message container (optional) + * @property {HTMLDivElement} [messageError] - Message error message container (optional) + * @property {HTMLDivElement} [sendOnError] - Send on error message container (optional) + * @property {HTMLSpanElement} [characterCount] - Character count display element (optional) + * @property {HTMLDivElement} [liveRegion] - Live region for screen reader announcements (optional) + */ + +/** + * @extends {Component} + */ +class GiftCardRecipientForm extends Component { + static DeliveryMode = { + SELF: 'self', // Send to my email + RECIPIENT: 'recipient_form', // Send to recipient's email with form + }; + + #currentMode = GiftCardRecipientForm.DeliveryMode.SELF; + + // Store bound event handlers for cleanup + /** @type {(() => void) | null} */ + #updateCharacterCountBound = null; + /** @type {((event: Event) => void) | null} */ + #displayCartErrorBound = null; + /** @type {(() => void) | null} */ + #cartAddEventBound = null; + + requiredRefs = [ + 'myEmailButton', + 'recipientEmailButton', + 'recipientFields', + 'recipientEmail', + 'recipientName', + 'recipientMessage', + 'recipientSendOn', + ]; + + /** + * Get all recipient input fields + * @returns {(HTMLInputElement | HTMLTextAreaElement)[]} Array of input fields + */ + get #inputFields() { + return [this.refs.recipientEmail, this.refs.recipientName, this.refs.recipientMessage, this.refs.recipientSendOn]; + } + + connectedCallback() { + super.connectedCallback(); + this.#initializeForm(); + + this.#updateCharacterCountBound = () => this.#updateCharacterCount(); + this.refs.recipientMessage.addEventListener('input', this.#updateCharacterCountBound); + + this.#displayCartErrorBound = this.#displayCartError.bind(this); + // @ts-ignore - #displayCartErrorBound is guaranteed to be non-null here + document.addEventListener(ThemeEvents.cartError, this.#displayCartErrorBound); + + this.#cartAddEventBound = () => this.#handleCartAdd(); + document.addEventListener(ThemeEvents.cartUpdate, this.#cartAddEventBound); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + if (this.#updateCharacterCountBound) { + this.refs.recipientMessage.removeEventListener('input', this.#updateCharacterCountBound); + this.#updateCharacterCountBound = null; + } + + if (this.#displayCartErrorBound) { + document.removeEventListener(ThemeEvents.cartError, this.#displayCartErrorBound); + this.#displayCartErrorBound = null; + } + + if (this.#cartAddEventBound) { + document.removeEventListener(ThemeEvents.cartUpdate, this.#cartAddEventBound); + this.#cartAddEventBound = null; + } + } + + /** + * Initialize form with default state, self delivery is selected by default + */ + #initializeForm() { + this.#updateButtonStates(GiftCardRecipientForm.DeliveryMode.SELF); + + this.refs.recipientFields.hidden = true; + + this.#clearRecipientFields(); + this.#disableRecipientFields(); + this.#setDateConstraints(); + } + + /** + * Handle toggle between my email and recipient email + * @param {string} mode - Delivery mode (either 'self' or 'recipient_form') + * @param {Event} _event - Change event (unused) + */ + toggleRecipientForm(mode, _event) { + // Validate mode + if (!Object.values(GiftCardRecipientForm.DeliveryMode).includes(mode)) { + throw new Error( + `Invalid delivery mode: ${mode}. Must be one of: ${Object.values(GiftCardRecipientForm.DeliveryMode).join( + ', ' + )}` + ); + } + + if (this.#currentMode === mode) return; + this.#currentMode = mode; + + this.#updateFormState(); + } + + /** + * Update form state based on current mode + */ + #updateFormState() { + const { DeliveryMode } = GiftCardRecipientForm; + const isRecipientMode = this.#currentMode === DeliveryMode.RECIPIENT; + + this.#updateButtonStates(this.#currentMode); + + this.refs.recipientFields.hidden = !isRecipientMode; + + if (isRecipientMode) { + this.#enableRecipientFields(); + + this.#updateCharacterCount(); + + // Announce to screen readers + if (this.refs.liveRegion) { + this.refs.liveRegion.textContent = + Theme.translations?.recipient_form_fields_visible || 'Recipient form fields are now visible'; + } + + // Focus first field for accessibility + this.refs.recipientEmail.focus(); + } else { + this.#clearRecipientFields(); + this.#disableRecipientFields(); + + // Announce to screen readers + if (this.refs.liveRegion) { + this.refs.liveRegion.textContent = + Theme.translations?.recipient_form_fields_hidden || 'Recipient form fields are now hidden'; + } + } + + this.dispatchEvent( + new CustomEvent('recipient:toggle', { + detail: { + mode: this.#currentMode, + recipientFormVisible: isRecipientMode, + }, + bubbles: true, + }) + ); + } + + /** + * Update radio button states + * @param {string} mode - Current delivery mode + */ + #updateButtonStates(mode) { + const { DeliveryMode } = GiftCardRecipientForm; + + switch (mode) { + case DeliveryMode.SELF: + this.refs.myEmailButton.checked = true; + this.refs.recipientEmailButton.checked = false; + break; + + case DeliveryMode.RECIPIENT: + this.refs.myEmailButton.checked = false; + this.refs.recipientEmailButton.checked = true; + break; + + default: + console.warn(`Unknown delivery mode: ${mode}`); + // Default to self delivery + this.refs.myEmailButton.checked = true; + this.refs.recipientEmailButton.checked = false; + } + } + + /** + * Clear all recipient form fields + */ + #clearRecipientFields() { + for (const field of this.#inputFields) { + field.value = ''; + } + + this.#updateCharacterCount(); + this.#clearErrorMessages(); + } + + /** + * Disable recipient form fields when sending to self + */ + #disableRecipientFields() { + for (const field of this.#inputFields) { + field.disabled = true; + field.removeAttribute('required'); + field.removeAttribute('aria-invalid'); + field.removeAttribute('aria-describedby'); + } + + // Remove control field when sending to self + const controlFlag = this.querySelector('input[name="properties[__shopify_send_gift_card_to_recipient]"]'); + if (controlFlag) { + controlFlag.remove(); + } + + if (this.refs.timezoneOffset) { + this.refs.timezoneOffset.disabled = true; + this.refs.timezoneOffset.value = ''; + } + + this.#clearErrorMessages(); + } + + /** + * Enable recipient form fields when sending to recipient + */ + #enableRecipientFields() { + for (const field of this.#inputFields) { + field.disabled = false; + if (field === this.refs.recipientEmail) { + field.setAttribute('required', 'required'); + } + } + + // Add control field when sending to recipient + let controlFlag = this.querySelector('input[name="properties[__shopify_send_gift_card_to_recipient]"]'); + if (!controlFlag) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'properties[__shopify_send_gift_card_to_recipient]'; + input.value = 'on'; + this.appendChild(input); + } + + // Enable and set timezone offset + if (this.refs.timezoneOffset) { + this.refs.timezoneOffset.disabled = false; + this.refs.timezoneOffset.value = new Date().getTimezoneOffset().toString(); + } + + // Set date constraints when enabling fields + this.#setDateConstraints(); + } + + /** + * Update character count display + */ + #updateCharacterCount() { + if (!this.refs.characterCount) return; + + const currentLength = this.refs.recipientMessage.value.length; + const maxLength = this.refs.recipientMessage.maxLength; + + const template = this.refs.characterCount.getAttribute('data-template'); + if (!template) return; + + const updatedText = template.replace('[current]', currentLength.toString()).replace('[max]', maxLength.toString()); + + this.refs.characterCount.textContent = updatedText; + } + + /** + * Set date constraints for the send on date picker + * Prevents selecting past dates and limits to 90 days in the future + */ + #setDateConstraints() { + const today = new Date(); + const maxDate = new Date(); + maxDate.setDate(today.getDate() + 90); + + // Format dates as YYYY-MM-DD + /** + * @param {Date} date + * @returns {string} + */ + const formatDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + this.refs.recipientSendOn.setAttribute('min', formatDate(today)); + this.refs.recipientSendOn.setAttribute('max', formatDate(maxDate)); + } + + /** + * Handles cart error events + * @param {CartErrorEvent} event - The cart error event + */ + #displayCartError(event) { + if (event.detail?.data) { + const { message, errors, description } = event.detail.data; + + // Display the error message + if (errors && typeof errors === 'object') { + this.#displayErrorMessage(message || 'There was an error', errors); + } else if (message) { + this.#displayErrorMessage(message, description); + } + } + } + + /** + * Display error messages in the appropriate error containers + * @param {string} title - The main error message title + * @param {Object} body - Error details + */ + #displayErrorMessage(title, body) { + this.#clearErrorMessages(); + + if (typeof body === 'object' && body !== null) { + /** @type {Record} */ + const fieldMap = { + email: { inputRef: 'recipientEmail', errorRef: 'emailError' }, + name: { inputRef: 'recipientName', errorRef: 'nameError' }, + message: { inputRef: 'recipientMessage', errorRef: 'messageError' }, + send_on: { inputRef: 'recipientSendOn', errorRef: 'sendOnError' }, + }; + + for (const [field, errorMessages] of Object.entries(body)) { + const fieldConfig = fieldMap[field]; + if (!fieldConfig) continue; + + const { inputRef, errorRef } = fieldConfig; + const errorContainer = this.refs[errorRef]; + const inputElement = this.refs[inputRef]; + + if (errorContainer && errorContainer instanceof HTMLElement) { + const errorTextElement = errorContainer.querySelector('span'); + if (errorTextElement) { + const message = Array.isArray(errorMessages) ? errorMessages.join(', ') : errorMessages; + errorTextElement.textContent = `${message}.`; + } + + errorContainer.classList.remove('hidden'); + } + + if (inputElement && inputElement instanceof HTMLElement) { + // Set ARIA attributes for accessibility + inputElement.setAttribute('aria-invalid', 'true'); + const errorId = `RecipientForm-${field}-error-${this.dataset.sectionId || 'default'}`; + inputElement.setAttribute('aria-describedby', errorId); + } + } + } + + // Announce errors to screen readers + if (this.refs.liveRegion) { + this.refs.liveRegion.textContent = + title || Theme.translations?.recipient_form_error || 'There was an error with the form submission'; + } + } + + /** + * Clear all error messages and reset ARIA attributes + */ + #clearErrorMessages() { + // List of error container refs + const errorRefs = ['emailError', 'nameError', 'messageError', 'sendOnError']; + + for (const errorRef of errorRefs) { + const errorContainer = this.refs[errorRef]; + if (errorContainer && errorContainer instanceof HTMLElement) { + errorContainer.classList.add('hidden'); + const errorTextElement = errorContainer.querySelector('span'); + if (errorTextElement) { + errorTextElement.textContent = ''; + } + } + } + + // Remove ARIA attributes from all input fields + for (const field of this.#inputFields) { + field.removeAttribute('aria-invalid'); + field.removeAttribute('aria-describedby'); + } + + // Clear live region announcement + if (this.refs.liveRegion) { + this.refs.liveRegion.textContent = ''; + } + } + + #handleCartAdd() { + this.#clearErrorMessages(); + } +} + +// Register the custom element +customElements.define('gift-card-recipient-form', GiftCardRecipientForm); diff --git a/assets/global.d.ts b/assets/global.d.ts new file mode 100644 index 000000000..9b964d593 --- /dev/null +++ b/assets/global.d.ts @@ -0,0 +1,73 @@ +export {}; + +declare global { + interface Shopify { + country: string; + currency: { + active: string; + rate: string; + }; + designMode: boolean; + locale: string; + shop: string; + loadFeatures(features: ShopifyFeature[], callback?: LoadCallback): void; + ModelViewerUI?: ModelViewer; + visualPreviewMode: boolean; + } + + interface Theme { + translations: Record; + routes: { + cart_add_url: string; + cart_change_url: string; + cart_update_url: string; + cart_url: string; + predictive_search_url: string; + search_url: string; + }; + utilities: { + scheduler: { + schedule: (task: () => void) => void; + }; + }; + template: { + name: string; + }; + } + + interface Window { + Shopify: Shopify; + } + + declare const Shopify: Shopify; + declare const Theme: Theme; + + type LoadCallback = (error: Error | undefined) => void; + + // Refer to https://github.com/Shopify/shopify/blob/main/areas/core/shopify/app/assets/javascripts/storefront/load_feature/load_features.js + interface ShopifyFeature { + name: string; + version: string; + onLoad?: LoadCallback; + } + + // Refer to https://github.com/Shopify/model-viewer-ui/blob/main/src/js/model-viewer-ui.js + interface ModelViewer { + new ( + element: Element, + options?: { + focusOnPlay?: boolean; + } + ): ModelViewer; + play(): void; + pause(): void; + toggleFullscreen(): void; + zoom(amount: number): void; + destroy(): void; + } + + // Device Memory API - https://developer.mozilla.org/en-US/docs/Web/API/Navigator/deviceMemory + interface Navigator { + readonly deviceMemory?: number; + } +} diff --git a/assets/header-drawer.js b/assets/header-drawer.js new file mode 100644 index 000000000..e32e26dce --- /dev/null +++ b/assets/header-drawer.js @@ -0,0 +1,188 @@ +import { Component } from '@theme/component'; +import { trapFocus, removeTrapFocus } from '@theme/focus'; +import { onAnimationEnd, removeWillChangeOnAnimationEnd } from '@theme/utilities'; + +/** + * A custom element that manages the main menu drawer. + * + * @typedef {object} Refs + * @property {HTMLDetailsElement} details - The details element. + * @property {HTMLDivElement} menuDrawer - The slideable drawer panel containing the menu. + * + * @extends {Component} + */ +class HeaderDrawer extends Component { + requiredRefs = ['details', 'menuDrawer']; + + connectedCallback() { + super.connectedCallback(); + + this.addEventListener('keyup', this.#onKeyUp); + this.#setupAnimatedElementListeners(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('keyup', this.#onKeyUp); + } + + /** + * Close the main menu drawer when the Escape key is pressed + * @param {KeyboardEvent} event + */ + #onKeyUp = (event) => { + if (event.key !== 'Escape') return; + + this.#close(this.#getDetailsElement(event)); + }; + + /** + * @returns {boolean} Whether the main menu drawer is open + */ + get isOpen() { + return this.refs.details.hasAttribute('open'); + } + + /** + * Get the closest details element to the event target + * @param {Event | undefined} event + * @returns {HTMLDetailsElement} + */ + #getDetailsElement(event) { + if (!(event?.target instanceof Element)) return this.refs.details; + + return event.target.closest('details') ?? this.refs.details; + } + + /** + * Toggle the main menu drawer + */ + toggle() { + return this.isOpen ? this.close() : this.open(); + } + + /** + * Open the closest drawer or the main menu drawer + * @param {string} [target] + * @param {Event} [event] + */ + open(target, event) { + const details = this.#getDetailsElement(event); + const summary = details.querySelector('summary'); + + if (!summary) return; + + summary.setAttribute('aria-expanded', 'true'); + + this.preventInitialAccordionAnimations(details); + requestAnimationFrame(() => { + details.classList.add('menu-open'); + + if (target) { + this.refs.menuDrawer.classList.add('menu-drawer--has-submenu-opened'); + } + + // Wait for the drawer animation to complete before trapping focus + const drawer = details.querySelector('.menu-drawer, .menu-drawer__submenu'); + onAnimationEnd(drawer || details, () => trapFocus(details), { subtree: false }); + }); + } + + /** + * Go back or close the main menu drawer + * @param {Event} [event] + */ + back(event) { + this.#close(this.#getDetailsElement(event)); + } + + /** + * Close the main menu drawer + */ + close() { + this.#close(this.refs.details); + } + + /** + * Close the closest menu or submenu that is open + * + * @param {HTMLDetailsElement} details + */ + #close(details) { + const summary = details.querySelector('summary'); + + if (!summary) return; + + summary.setAttribute('aria-expanded', 'false'); + details.classList.remove('menu-open'); + this.refs.menuDrawer.classList.remove('menu-drawer--has-submenu-opened'); + + // Wait for the .menu-drawer element's transition, not the entire details subtree + // This avoids waiting for child accordion/resource-card animations which can cause issues on Firefox + const drawer = details.querySelector('.menu-drawer, .menu-drawer__submenu'); + + onAnimationEnd( + drawer || details, + () => { + reset(details); + if (details === this.refs.details) { + removeTrapFocus(); + const openDetails = this.querySelectorAll('details[open]:not(accordion-custom > details)'); + openDetails.forEach(reset); + } else { + trapFocus(this.refs.details); + } + }, + { subtree: false } + ); + } + + /** + * Attach animationend event listeners to all animated elements to remove will-change after animation + * to remove the stacking context and allow submenus to be positioned correctly + */ + #setupAnimatedElementListeners() { + const allAnimated = this.querySelectorAll('.menu-drawer__animated-element'); + allAnimated.forEach((element) => { + element.addEventListener('animationend', removeWillChangeOnAnimationEnd); + }); + } + + /** + * Temporarily disables accordion animations to prevent unwanted transitions when the drawer opens. + * Adds a no-animation class to accordion content elements, then removes it after 100ms to + * re-enable animations for user interactions. + * @param {HTMLDetailsElement} details - The details element containing the accordions + */ + preventInitialAccordionAnimations(details) { + const content = details.querySelectorAll('accordion-custom .details-content'); + + content.forEach((element) => { + if (element instanceof HTMLElement) { + element.classList.add('details-content--no-animation'); + } + }); + setTimeout(() => { + content.forEach((element) => { + if (element instanceof HTMLElement) { + element.classList.remove('details-content--no-animation'); + } + }); + }, 100); + } +} + +if (!customElements.get('header-drawer')) { + customElements.define('header-drawer', HeaderDrawer); +} + +/** + * Reset an open details element to its original state + * + * @param {HTMLDetailsElement} element + */ +function reset(element) { + element.classList.remove('menu-open'); + element.removeAttribute('open'); + element.querySelector('summary')?.setAttribute('aria-expanded', 'false'); +} diff --git a/assets/header-menu.js b/assets/header-menu.js new file mode 100644 index 000000000..61c20a0c9 --- /dev/null +++ b/assets/header-menu.js @@ -0,0 +1,241 @@ +import { Component } from '@theme/component'; +import { debounce, onDocumentLoaded, setHeaderMenuStyle } from '@theme/utilities'; +import { MegaMenuHoverEvent } from '@theme/events'; + +/** + * A custom element that manages a header menu. + * + * @typedef {Object} State + * @property {HTMLElement | null} activeItem - The currently active menu item. + * + * @typedef {object} Refs + * @property {HTMLElement} overflowMenu - The overflow menu. + * @property {HTMLElement[]} [submenu] - The submenu in each respective menu item. + * + * @extends {Component} + */ +class HeaderMenu extends Component { + requiredRefs = ['overflowMenu']; + + /** + * @type {MutationObserver | null} + */ + #submenuMutationObserver = null; + + connectedCallback() { + super.connectedCallback(); + + this.overflowMenu?.addEventListener('pointerleave', () => this.#deactivate()); + // on load, cache the max height of the submenu so you can use it in a translate + this.#cacheMaxOverflowMenuHeight(); + + onDocumentLoaded(this.#preloadImages); + window.addEventListener('resize', this.#resizeListener); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('resize', this.#resizeListener); + this.#cleanupMutationObserver(); + } + + /** + * Debounced resize event listener to recalculate menu style + */ + #resizeListener = debounce(() => { + this.#cacheMaxOverflowMenuHeight(); + setHeaderMenuStyle(); + }, 100); + + /** + * @type {State} + */ + #state = { + activeItem: null, + }; + + /** + * Get the overflow menu + */ + get overflowMenu() { + return /** @type {HTMLElement | null} */ (this.refs.overflowMenu?.shadowRoot?.querySelector('[part="overflow"]')); + } + + /** + * Whether the overflow menu is hovered + * @returns {boolean} + */ + get overflowHovered() { + return this.refs.overflowMenu?.matches(':hover') ?? false; + } + + get headerComponent() { + return /** @type {HTMLElement | null} */ (this.closest('header-component')); + } + + /** + * Activate the selected menu item immediately + * @param {PointerEvent | FocusEvent} event + */ + activate = (event) => { + this.dispatchEvent(new MegaMenuHoverEvent()); + + if (!(event.target instanceof Element)) return; + + let item = findMenuItem(event.target); + + if (!item || item == this.#state.activeItem) return; + + const isDefaultSlot = event.target.slot === ''; + + this.dataset.overflowExpanded = (!isDefaultSlot).toString(); + + const previouslyActiveItem = this.#state.activeItem; + + if (previouslyActiveItem) { + previouslyActiveItem.ariaExpanded = 'false'; + } + + this.#state.activeItem = item; + this.ariaExpanded = 'true'; + item.ariaExpanded = 'true'; + + let submenu = findSubmenu(item); + + if (!submenu && !isDefaultSlot) { + submenu = this.overflowMenu; + } + + if (submenu) { + // Mark submenu as active for content-visibility optimization + submenu.dataset.active = ''; + + // Cleanup any existing mutation observer from previous menu activations + this.#cleanupMutationObserver(); + + // Monitor DOM mutations to catch deferred content injection (from section hydration) + this.#submenuMutationObserver = new MutationObserver(() => { + requestAnimationFrame(() => { + // Double requestAnimationFrame to ensure the height is properly calculated and not defaulting to the contain-intrinsic-size + requestAnimationFrame(() => { + if (submenu.offsetHeight > 0) { + this.headerComponent?.style.setProperty('--submenu-height', `${submenu.offsetHeight}px`); + this.#cleanupMutationObserver(); + } + }); + }); + }); + this.#submenuMutationObserver.observe(submenu, {childList: true, subtree: true}); + + // Auto-disconnect after 500ms to prevent memory leaks + setTimeout(() => { + this.#cleanupMutationObserver(); + }, 500); + } + + const submenuHeight = submenu ? submenu.offsetHeight : 0; + + this.headerComponent?.style.setProperty('--submenu-height', `${submenuHeight}px`); + this.style.setProperty('--submenu-opacity', '1'); + }; + + /** + * Deactivate the active item after a delay + * @param {PointerEvent | FocusEvent} event + */ + deactivate(event) { + if (!(event.target instanceof Element)) return; + + const menu = findSubmenu(this.#state.activeItem); + const isMovingWithinMenu = event.relatedTarget instanceof Node && menu?.contains(document.activeElement); + const isMovingToSubmenu = + event.relatedTarget instanceof Node && event.type === 'blur' && menu?.contains(event.relatedTarget); + const isMovingToOverflowMenu = + event.relatedTarget instanceof Node && event.relatedTarget.parentElement?.matches('[slot="overflow"]'); + + if (isMovingWithinMenu || isMovingToOverflowMenu || isMovingToSubmenu) return; + + this.#deactivate(); + } + + /** + * Deactivate the active item immediately + * @param {HTMLElement | null} [item] + */ + #deactivate = (item = this.#state.activeItem) => { + if (!item || item != this.#state.activeItem) return; + if (this.overflowHovered) return; + + this.headerComponent?.style.setProperty('--submenu-height', '0px'); + this.style.setProperty('--submenu-opacity', '0'); + this.dataset.overflowExpanded = 'false'; + + const submenu = findSubmenu(item); + + this.#state.activeItem = null; + this.ariaExpanded = 'false'; + item.ariaExpanded = 'false'; + + // Remove active state from submenu after animation completes + if (submenu) { + delete submenu.dataset.active; + } + }; + + /** + * Preload images that are set to load lazily. + */ + #preloadImages = () => { + const images = this.querySelectorAll('img[loading="lazy"]'); + images?.forEach((image) => image.removeAttribute('loading')); + }; + + /** + * Caches the maximum height of all submenus for consistent animations + * Stores the value in a CSS custom property for use in transitions + */ + #cacheMaxOverflowMenuHeight() { + const submenus = this.querySelectorAll('[ref="submenu[]"]'); + const maxHeight = Math.max( + ...Array.from(submenus) + .filter((submenu) => submenu instanceof HTMLElement) + .map((submenu) => submenu.offsetHeight) + ); + this.headerComponent?.style.setProperty('--submenu-max-height', `${maxHeight}px`); + } + + #cleanupMutationObserver() { + this.#submenuMutationObserver?.disconnect(); + this.#submenuMutationObserver = null; + } +} + +if (!customElements.get('header-menu')) { + customElements.define('header-menu', HeaderMenu); +} + +/** + * Find the closest menu item. + * @param {Element | null | undefined} element + * @returns {HTMLElement | null} + */ +function findMenuItem(element) { + if (!(element instanceof Element)) return null; + + if (element?.matches('[slot="more"')) { + // Select the first overflowing menu item when hovering over the "More" item + return findMenuItem(element.parentElement?.querySelector('[slot="overflow"]')); + } + + return element?.querySelector('[ref="menuitem"]'); +} + +/** + * Find the closest submenu. + * @param {Element | null | undefined} element + * @returns {HTMLElement | null} + */ +function findSubmenu(element) { + const submenu = element?.parentElement?.querySelector('[ref="submenu[]"]'); + return submenu instanceof HTMLElement ? submenu : null; +} diff --git a/assets/header.js b/assets/header.js new file mode 100644 index 000000000..a67565c26 --- /dev/null +++ b/assets/header.js @@ -0,0 +1,274 @@ +import { Component } from '@theme/component'; +import { onDocumentLoaded, changeMetaThemeColor, setHeaderMenuStyle } from '@theme/utilities'; + +/** + * @typedef {Object} HeaderComponentRefs + * @property {HTMLDivElement} headerDrawerContainer - The header drawer container element + * @property {HTMLElement} headerMenu - The header menu element + * @property {HTMLElement} headerRowTop - The header top row element + */ + +/** + * @typedef {CustomEvent<{ minimumReached: boolean }>} OverflowMinimumEvent + */ + +/** + * A custom element that manages the site header. + * + * @extends {Component} + */ + +class HeaderComponent extends Component { + requiredRefs = ['headerDrawerContainer', 'headerMenu', 'headerRowTop']; + + /** + * Width of window when header drawer was hidden + * @type {number | null} + */ + #menuDrawerHiddenWidth = null; + + /** + * An intersection observer for monitoring sticky header position + * @type {IntersectionObserver | null} + */ + #intersectionObserver = null; + + /** + * Whether the header has been scrolled offscreen, when sticky behavior is 'scroll-up' + * @type {boolean} + */ + #offscreen = false; + + /** + * The last recorded scrollTop of the document, when sticky behavior is 'scroll-up + * @type {number} + */ + #lastScrollTop = 0; + + /** + * A timeout to allow for hiding animation, when sticky behavior is 'scroll-up' + * @type {number | null} + */ + #timeout = null; + + /** + * RAF ID for scroll handler throttling + * @type {number | null} + */ + #scrollRafId = null; + + /** + * Keeps the global `--header-height` custom property up to date, + * which other theme components can then consume + */ + #resizeObserver = new ResizeObserver(([entry]) => { + if (!entry || !entry.borderBoxSize[0]) return; + + // The initial height is calculated using the .offsetHeight property, which returns an integer. + // We round to the nearest integer to avoid unnecessaary reflows. + const roundedHeaderHeight = Math.round(entry.borderBoxSize[0].blockSize); + document.body.style.setProperty('--header-height', `${roundedHeaderHeight}px`); + + // Check if the menu drawer should be hidden in favor of the header menu + if (this.#menuDrawerHiddenWidth && window.innerWidth > this.#menuDrawerHiddenWidth) { + this.#updateMenuVisibility(false); + } + }); + + /** + * Observes the header while scrolling the viewport to track when its actively sticky + * @param {Boolean} alwaysSticky - Determines if we need to observe when the header is offscreen + */ + #observeStickyPosition = (alwaysSticky = true) => { + if (this.#intersectionObserver) return; + + const config = { + threshold: alwaysSticky ? 1 : 0, + }; + + this.#intersectionObserver = new IntersectionObserver(([entry]) => { + if (!entry) return; + + const { isIntersecting } = entry; + + if (alwaysSticky) { + this.dataset.stickyState = isIntersecting ? 'inactive' : 'active'; + if (this.dataset.themeColor) changeMetaThemeColor(this.dataset.themeColor); + } else { + this.#offscreen = !isIntersecting || this.dataset.stickyState === 'active'; + } + }, config); + + this.#intersectionObserver.observe(this); + }; + + /** + * Handles the overflow minimum event from the header menu + * @param {OverflowMinimumEvent} event + */ + #handleOverflowMinimum = (event) => { + this.#updateMenuVisibility(event.detail.minimumReached); + }; + + /** + * Updates the visibility of the menu and drawer + * @param {boolean} hideMenu - Whether to hide the menu and show the drawer + */ + #updateMenuVisibility(hideMenu) { + if (hideMenu) { + this.#menuDrawerHiddenWidth = window.innerWidth; + } else { + this.#menuDrawerHiddenWidth = null; + } + setHeaderMenuStyle(); + } + + #handleWindowScroll = () => { + if (this.#scrollRafId !== null) return; + + this.#scrollRafId = requestAnimationFrame(() => { + this.#scrollRafId = null; + this.#updateScrollState(); + }); + }; + + #updateScrollState = () => { + const stickyMode = this.getAttribute('sticky'); + if (!this.#offscreen && stickyMode !== 'always') return; + + const scrollTop = document.scrollingElement?.scrollTop ?? 0; + const headerTop = this.getBoundingClientRect().top; + const isScrollingUp = scrollTop < this.#lastScrollTop; + const isAtTop = headerTop >= 0; + + if (this.#timeout) { + clearTimeout(this.#timeout); + this.#timeout = null; + } + + if (stickyMode === 'always') { + if (isAtTop) { + this.dataset.scrollDirection = 'none'; + } else if (isScrollingUp) { + this.dataset.scrollDirection = 'up'; + } else { + this.dataset.scrollDirection = 'down'; + } + + this.#lastScrollTop = scrollTop; + return; + } + + if (isScrollingUp) { + if (isAtTop) { + // reset sticky state when header is scrolled up to natural position + this.#offscreen = false; + this.dataset.stickyState = 'inactive'; + this.dataset.scrollDirection = 'none'; + } else { + // show sticky header when scrolling up + this.dataset.stickyState = 'active'; + this.dataset.scrollDirection = 'up'; + } + } else if (this.dataset.stickyState === 'active') { + this.dataset.scrollDirection = 'none'; + + this.dataset.stickyState = 'idle'; + } else { + this.dataset.scrollDirection = 'none'; + this.dataset.stickyState = 'idle'; + } + + this.#lastScrollTop = scrollTop; + }; + + connectedCallback() { + super.connectedCallback(); + this.#resizeObserver.observe(this); + this.addEventListener('overflowMinimum', this.#handleOverflowMinimum); + + const stickyMode = this.getAttribute('sticky'); + if (stickyMode) { + this.#observeStickyPosition(stickyMode === 'always'); + + if (stickyMode === 'scroll-up' || stickyMode === 'always') { + document.addEventListener('scroll', this.#handleWindowScroll); + } + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#resizeObserver.disconnect(); + this.#intersectionObserver?.disconnect(); + this.removeEventListener('overflowMinimum', this.#handleOverflowMinimum); + document.removeEventListener('scroll', this.#handleWindowScroll); + if (this.#scrollRafId !== null) { + cancelAnimationFrame(this.#scrollRafId); + this.#scrollRafId = null; + } + document.body.style.setProperty('--header-height', '0px'); + } +} + +if (!customElements.get('header-component')) { + customElements.define('header-component', HeaderComponent); +} + +onDocumentLoaded(() => { + const header = document.querySelector('header-component'); + const headerGroup = document.querySelector('#header-group'); + + // Note: Initial header heights are set via inline script in theme.liquid + // This ResizeObserver handles dynamic updates after page load + + // Update header group height on resize of any child + if (headerGroup) { + const resizeObserver = new ResizeObserver((entries) => { + const headerGroupHeight = entries.reduce((totalHeight, entry) => { + if ( + entry.target !== header || + (header.hasAttribute('transparent') && header.parentElement?.nextElementSibling) + ) { + return totalHeight + (entry.borderBoxSize[0]?.blockSize ?? 0); + } + return totalHeight; + }, 0); + // The initial height is calculated using the .offsetHeight property, which returns an integer. + // We round to the nearest integer to avoid unnecessaary reflows. + const roundedHeaderGroupHeight = Math.round(headerGroupHeight); + document.body.style.setProperty('--header-group-height', `${roundedHeaderGroupHeight}px`); + }); + + if (header instanceof HTMLElement) { + resizeObserver.observe(header); + } + + // Observe all children of the header group + const children = headerGroup.children; + for (let i = 0; i < children.length; i++) { + const element = children[i]; + if (element instanceof HTMLElement) { + resizeObserver.observe(element); + } + } + + // Also observe the header group itself for child changes + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + // Re-observe all children when the list changes + const children = headerGroup.children; + for (let i = 0; i < children.length; i++) { + const element = children[i]; + if (element instanceof HTMLElement) { + resizeObserver.observe(element); + } + } + } + } + }); + + mutationObserver.observe(headerGroup, { childList: true }); + } +}); diff --git a/assets/icon-account.svg b/assets/icon-account.svg index ed2687e40..c808d3969 100644 --- a/assets/icon-account.svg +++ b/assets/icon-account.svg @@ -1,6 +1 @@ - - - - \ No newline at end of file + diff --git a/assets/icon-add-to-cart.svg b/assets/icon-add-to-cart.svg new file mode 100644 index 000000000..1c0d45bd9 --- /dev/null +++ b/assets/icon-add-to-cart.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-arrow.svg b/assets/icon-arrow.svg new file mode 100644 index 000000000..16f6d5713 --- /dev/null +++ b/assets/icon-arrow.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-available.svg b/assets/icon-available.svg new file mode 100644 index 000000000..7f2c4d0f4 --- /dev/null +++ b/assets/icon-available.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-caret.svg b/assets/icon-caret.svg new file mode 100644 index 000000000..801553ab3 --- /dev/null +++ b/assets/icon-caret.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-cart.svg b/assets/icon-cart.svg index 0a8d0a9b1..a9a2fb206 100644 --- a/assets/icon-cart.svg +++ b/assets/icon-cart.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/assets/icon-checkmark-burst.svg b/assets/icon-checkmark-burst.svg new file mode 100644 index 000000000..39df607bf --- /dev/null +++ b/assets/icon-checkmark-burst.svg @@ -0,0 +1,32 @@ + diff --git a/assets/icon-checkmark.svg b/assets/icon-checkmark.svg new file mode 100644 index 000000000..3c12fa0b9 --- /dev/null +++ b/assets/icon-checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-chevron-left.svg b/assets/icon-chevron-left.svg new file mode 100644 index 000000000..00f88393b --- /dev/null +++ b/assets/icon-chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-chevron-right.svg b/assets/icon-chevron-right.svg new file mode 100644 index 000000000..e3e4b16d3 --- /dev/null +++ b/assets/icon-chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-close.svg b/assets/icon-close.svg new file mode 100644 index 000000000..399230076 --- /dev/null +++ b/assets/icon-close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-delete.svg b/assets/icon-delete.svg new file mode 100644 index 000000000..c85af510a --- /dev/null +++ b/assets/icon-delete.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-discount.svg b/assets/icon-discount.svg new file mode 100644 index 000000000..096c64219 --- /dev/null +++ b/assets/icon-discount.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-double-chevron.svg b/assets/icon-double-chevron.svg new file mode 100644 index 000000000..015b06bf2 --- /dev/null +++ b/assets/icon-double-chevron.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-error.svg b/assets/icon-error.svg new file mode 100644 index 000000000..a282b048b --- /dev/null +++ b/assets/icon-error.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icon-external.svg b/assets/icon-external.svg new file mode 100644 index 000000000..fbd780b89 --- /dev/null +++ b/assets/icon-external.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-filter.svg b/assets/icon-filter.svg new file mode 100644 index 000000000..060a5d3f1 --- /dev/null +++ b/assets/icon-filter.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-filters-close.svg b/assets/icon-filters-close.svg new file mode 100644 index 000000000..fde33377e --- /dev/null +++ b/assets/icon-filters-close.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icon-grid-default.svg b/assets/icon-grid-default.svg new file mode 100644 index 000000000..ee6db80d9 --- /dev/null +++ b/assets/icon-grid-default.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icon-grid-dense.svg b/assets/icon-grid-dense.svg new file mode 100644 index 000000000..804805035 --- /dev/null +++ b/assets/icon-grid-dense.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icon-info.svg b/assets/icon-info.svg new file mode 100644 index 000000000..390ecb54d --- /dev/null +++ b/assets/icon-info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icon-inventory.svg b/assets/icon-inventory.svg new file mode 100644 index 000000000..d0b3c462d --- /dev/null +++ b/assets/icon-inventory.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icon-menu.svg b/assets/icon-menu.svg new file mode 100644 index 000000000..2c5098734 --- /dev/null +++ b/assets/icon-menu.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-minus.svg b/assets/icon-minus.svg new file mode 100644 index 000000000..423ebbac1 --- /dev/null +++ b/assets/icon-minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-one-col-mobile.svg b/assets/icon-one-col-mobile.svg new file mode 100644 index 000000000..efd2fe341 --- /dev/null +++ b/assets/icon-one-col-mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-orders.svg b/assets/icon-orders.svg new file mode 100644 index 000000000..e78ac475c --- /dev/null +++ b/assets/icon-orders.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-pause.svg b/assets/icon-pause.svg new file mode 100644 index 000000000..7255cacbc --- /dev/null +++ b/assets/icon-pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-play.svg b/assets/icon-play.svg new file mode 100644 index 000000000..a979b2f45 --- /dev/null +++ b/assets/icon-play.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icon-plus.svg b/assets/icon-plus.svg new file mode 100644 index 000000000..b845e454d --- /dev/null +++ b/assets/icon-plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icon-reset.svg b/assets/icon-reset.svg new file mode 100644 index 000000000..182ed125e --- /dev/null +++ b/assets/icon-reset.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-search.svg b/assets/icon-search.svg new file mode 100644 index 000000000..83af677e1 --- /dev/null +++ b/assets/icon-search.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-shopify.svg b/assets/icon-shopify.svg new file mode 100644 index 000000000..b6a58bf8d --- /dev/null +++ b/assets/icon-shopify.svg @@ -0,0 +1 @@ + diff --git a/assets/icon-unavailable.svg b/assets/icon-unavailable.svg new file mode 100644 index 000000000..73dfbc913 --- /dev/null +++ b/assets/icon-unavailable.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/jsconfig.json b/assets/jsconfig.json new file mode 100644 index 000000000..a3b03770a --- /dev/null +++ b/assets/jsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "checkJs": true, + "target": "ES2020", + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, + "strictNullChecks": true, + "types": ["./global.d.ts"], + "paths": { + "@theme/*": ["./*"] + } + } +} diff --git a/assets/jumbo-text.js b/assets/jumbo-text.js new file mode 100644 index 000000000..e16ac3ed5 --- /dev/null +++ b/assets/jumbo-text.js @@ -0,0 +1,193 @@ +import { ResizeNotifier, prefersReducedMotion, yieldToMainThread } from '@theme/utilities'; +import { Component } from '@theme/component'; + +/** + * A custom element that automatically sizes text to fit its container width. + */ +class JumboText extends Component { + connectedCallback() { + super.connectedCallback(); + this.#setIntersectionObserver(); + + // We need window listener to account for flex containers not shrinking until we reset the font size. + window.addEventListener('resize', this.#windowResizeListener); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#resizeObserver.disconnect(); + this.#intersectionObserver?.disconnect(); + + window.removeEventListener('resize', this.#windowResizeListener); + } + + #firstResize = true; + + /** + * Sets the intersection observer to calculate the optimal font size when the text is in view. + */ + #setIntersectionObserver() { + // The threshold could be different based on the repetition of the animation. + this.#intersectionObserver = new IntersectionObserver( + (entries) => { + // We observe a single element, so we only need the latest entry. + const entry = entries[entries.length - 1]; + + if (!entry) { + return; + } + + // Initial calculation + if (entry.isIntersecting && this.#firstResize) { + this.#handleResize(entry.boundingClientRect.width); + } + + if (this.dataset.textEffect && this.dataset.textEffect !== 'none' && !prefersReducedMotion()) { + if (entry.intersectionRatio >= 0.3) { + this.classList.add('ready'); + if (this.dataset.animationRepeat === 'false') { + this.#intersectionObserver?.unobserve(entry.target); + } + // We need to wait for resize recalculations to apply before triggering transitions. + yieldToMainThread().then(() => { + this.classList.add('jumbo-text-visible'); + }); + } else { + this.classList.remove('ready', 'jumbo-text-visible'); + } + } + }, + { threshold: [0, 0.3] } + ); + + this.#intersectionObserver?.observe(this); + } + + /** + * Calculates the optimal font size to make the text fit the container. + * @param {number} containerWidth - The width of the jumbo-text element. + */ + #calculateOptimalFontSize = (containerWidth) => { + const { widestChild: firstPassWidestChild, widestChildWidth: firstPassWidestChildWidth } = this.#findWidestChild(); + if (!firstPassWidestChild || !firstPassWidestChildWidth) { + return; + } + + const currentFontSize = parseFloat(window.getComputedStyle(firstPassWidestChild).fontSize); + const firstPassFontSize = Math.round(((currentFontSize * containerWidth) / firstPassWidestChildWidth) * 100) / 100; + + // Disconnect the resize observer + this.#resizeObserver.disconnect(); + + this.style.fontSize = this.#clampFontSize(firstPassFontSize); + + // The way the text grows is mostly proportional, but not fully linear. + // Doing a single pass was good enough in 95% of cases, but we need a second one to dial in the final value. + const { widestChild: secondPassWidestChild, widestChildWidth: secondPassWidestChildWidth } = + this.#findWidestChild(); + if (!secondPassWidestChild || !secondPassWidestChildWidth) { + return; + } + + // The -0.15 was chosen by trial and error. It doesn't influence large font sizes much, but helps smaller ones fit better. + const secondPassFontSize = + Math.floor(((firstPassFontSize * containerWidth) / secondPassWidestChildWidth) * 100) / 100 - 0.15; + + if (secondPassFontSize !== firstPassFontSize) { + this.style.fontSize = this.#clampFontSize(secondPassFontSize); + } + + this.classList.add('ready'); + + this.#resizeObserver.observe(this); + }; + + #findWidestChild = () => { + let widestChild = null; + let widestChildWidth = 0; + + for (const child of this.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + + const { width: childWidth } = child.getBoundingClientRect(); + + if (!widestChild || childWidth > widestChildWidth) { + widestChildWidth = childWidth; + widestChild = child; + } + } + return { widestChild, widestChildWidth }; + }; + + /** + * Clamps the font size between a minimum and maximum value. + * @param {number} fontSize - The font size to clamp. + * @returns {string} The clamped font size with pixels suffix. + */ + #clampFontSize = (fontSize) => { + const minFontSize = 1; + const maxFontSize = 500; + + return `${Math.min(Math.max(fontSize, minFontSize), maxFontSize)}px`; + }; + + /** + * @param {number | undefined} containerWidth - The width of the element. + */ + #handleResize = (containerWidth = undefined) => { + // Check for empty text + if (!this.textContent?.trim()) { + return; + } + + if (containerWidth === undefined) { + containerWidth = this.offsetWidth; + } + + if (containerWidth <= 0) return; + + // Reset font size to make sure we allow the container to shrink if it needs to. + if (!this.#firstResize) { + this.classList.remove('ready'); + this.style.fontSize = ''; + } + + this.#calculateOptimalFontSize(containerWidth); + + this.#firstResize = false; + + if (this.dataset.capText === 'true') { + return; + } + + // We assume that the component won't be at the bottom of the page unless it's inside the last section. + const allSections = Array.from(document.querySelectorAll('.shopify-section')); + const lastSection = allSections[allSections.length - 1]; + + if (lastSection && !lastSection.contains(this)) { + return; + } + + // Check if jumbo text is close to the bottom of the page. If it is, then use `cap text` instead of `cap alphabetic`. + // This reserves space for descender characters so they don't overflow and cause extra space at the bottom of the page. + const rect = this.getBoundingClientRect(); + const bottom = rect.bottom + window.scrollY; + const distanceFromBottom = document.documentElement.offsetHeight - bottom; + this.dataset.capText = (distanceFromBottom <= 100).toString(); + }; + + #windowResizeListener = () => this.#handleResize(); + + #resizeObserver = new ResizeNotifier((entries) => this.#handleResize(entries[0]?.borderBoxSize?.[0]?.inlineSize)); + /** + * @type {IntersectionObserver | null} + */ + #intersectionObserver = null; +} + +// Register once +if (!customElements.get('jumbo-text')) { + customElements.define('jumbo-text', JumboText); +} diff --git a/assets/layered-slideshow.js b/assets/layered-slideshow.js new file mode 100644 index 000000000..ee2b84752 --- /dev/null +++ b/assets/layered-slideshow.js @@ -0,0 +1,602 @@ +import { Component } from '@theme/component'; +import { isMobileBreakpoint, mediaQueryLarge } from '@theme/utilities'; + +/** + * @typedef {Object} LayeredSlideshowRefs + * @property {HTMLElement} container + * @property {HTMLElement[]} tabs + * @property {HTMLElement[]} panels + */ + +/** + * @typedef {Object} DragState + * @property {number} target + * @property {number} start + * @property {number} max + * @property {number} activeSize - The resolved pixel size of the active panel at drag start + * @property {boolean} left + * @property {boolean} [dragging] + * @property {boolean} [prevent] + * @property {number} [progress] + */ + +const DRAG_THRESHOLD = 5; +const MAX_DRAG_WIDTH_RATIO = 0.8; +const DRAG_COMPLETE_THRESHOLD = 0.5; +const INACTIVE_SIZE = 56; // Px size of inactive tabs on desktop +const INACTIVE_MOBILE_SIZE = 44; // Px size of inactive tabs on mobile +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** @extends {Component} */ +export class LayeredSlideshowComponent extends Component { + requiredRefs = ['container']; + #active = 0; + /** @type {DragState | null} */ + #drag = null; + /** @type {AbortController | null} */ + #abort = null; + #isMobile = false; + /** @type {ResizeObserver | null} */ + #heightObserver = null; + /** @type {MutationObserver | null} */ + #contentObserver = null; + /** @type {ResizeObserver | null} */ + #containerObserver = null; + + /** @returns {number} The inactive tab size in pixels based on current viewport */ + get #inactiveSize() { + return this.#isMobile ? INACTIVE_MOBILE_SIZE : INACTIVE_SIZE; + } + + connectedCallback() { + super.connectedCallback(); + const { tabs } = this.refs; + if (!tabs?.length) return; + + this.#active = Math.max( + 0, + tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true') + ); + + this.#isMobile = isMobileBreakpoint(); + mediaQueryLarge.addEventListener('change', this.#handleMediaQueryChange); + + this.#containerObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentBoxSize) { + // Use contentBoxSize if available for better precision, or fallback to contentRect + const boxSize = entry.contentBoxSize[0]; + const isMobile = this.#isMobile; + + let size; + if (boxSize) { + size = isMobile ? boxSize.blockSize : boxSize.inlineSize; + } else { + size = isMobile ? entry.contentRect.height : entry.contentRect.width; + } + + this.#updateGridSizes(size); + } + } + }); + this.#containerObserver.observe(this.refs.container); + + this.#updateActiveTab(); + this.#setupEventListeners(); + this.#observeContentHeight(); + } + + #setupEventListeners() { + this.#abort?.abort(); + this.#abort = new AbortController(); + const opts = { signal: this.#abort.signal }; + const { container, tabs } = this.refs; + + this.addEventListener('keydown', (e) => this.#handleKeydown(e), opts); + + for (const [i, tab] of tabs.entries()) { + tab.addEventListener('click', (e) => this.#handleTabClick(e, i), opts); + tab.addEventListener('focus', (e) => this.#handleTabFocus(e, i), opts); + } + + this.#setupPanelFocusManagement(opts); + + if (!this.#isMobile) { + container.addEventListener('pointerdown', (e) => this.#startDrag(e), opts); + container.addEventListener('click', (e) => this.#preventClickDuringDrag(e), { ...opts, capture: true }); + } + } + + #handleKeydown(/** @type {KeyboardEvent} */ e) { + const target = /** @type {HTMLElement} */ (e.target); + if (target.getAttribute('role') !== 'tab') return; + + const { tabs } = this.refs; + if (!tabs) return; + + const i = tabs.indexOf(target); + const navMap = { + [this.#isMobile ? 'ArrowUp' : 'ArrowLeft']: -1, + [this.#isMobile ? 'ArrowDown' : 'ArrowRight']: 1, + Home: -i, + End: tabs.length - 1 - i, + }; + + const offset = navMap[e.key]; + if (offset !== undefined) { + e.preventDefault(); + const nextIndex = (i + offset + tabs.length) % tabs.length; + tabs[nextIndex]?.focus(); + this.#activate(nextIndex); + } + } + + #handleTabClick(/** @type {MouseEvent} */ e, /** @type {number} */ index) { + e.preventDefault(); + this.#activate(index); + } + + #handleTabFocus(/** @type {FocusEvent} */ e, /** @type {number} */ index) { + const target = /** @type {HTMLElement} */ (e.target); + if (target.matches(':focus-visible')) { + this.#activate(index); + } + } + + /** + * @param {AddEventListenerOptions & { signal: AbortSignal }} opts + */ + #setupPanelFocusManagement(opts) { + const { panels } = this.refs; + if (!panels) return; + + for (const [index, panel] of panels.entries()) { + panel.addEventListener('keydown', (event) => this.#handlePanelKeydown(event, index), opts); + } + } + + /** + * @param {KeyboardEvent} event + * @param {number} index + */ + #handlePanelKeydown(event, index) { + if (event.key !== 'Tab') return; + + const { panels } = this.refs; + const panel = /** @type {HTMLElement} */ (event.currentTarget); + const focusable = this.#getFocusableElements(panel); + const firstFocusable = focusable[0]; + const lastFocusable = focusable[focusable.length - 1]; + + if (event.shiftKey) { + const isAtStart = + (firstFocusable && document.activeElement === firstFocusable) || + (!focusable.length && document.activeElement === panel); + if (isAtStart && index > 0) { + event.preventDefault(); + this.#activate(index - 1); + this.#focusPanelEdge(index - 1, 'end'); + } + return; + } + + const isAtEnd = + (lastFocusable && document.activeElement === lastFocusable) || + (!focusable.length && document.activeElement === panel); + + if (isAtEnd && panels && index < panels.length - 1) { + event.preventDefault(); + this.#activate(index + 1); + this.#focusPanelEdge(index + 1, 'start'); + } + } + + /** + * @param {number} index + * @param {'start' | 'end'} [position] + */ + #focusPanelEdge(index, position = 'start') { + const panel = this.refs.panels?.[index]; + if (!panel) return; + + const focusable = this.#getFocusableElements(panel); + const target = position === 'end' ? focusable[focusable.length - 1] : focusable[0]; + + requestAnimationFrame(() => (target ?? panel).focus()); + } + + /** + * @param {HTMLElement} panel + * @returns {HTMLElement[]} + */ + #getFocusableElements(panel) { + return Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)) + .filter((el) => !el.closest('[inert]')) + .map((el) => /** @type {HTMLElement} */ (el)); + } + + #preventClickDuringDrag(/** @type {MouseEvent} */ e) { + const target = /** @type {HTMLElement} */ (e.target); + if (this.#drag?.prevent && target.closest('[role="tab"]')) { + e.stopImmediatePropagation(); + e.preventDefault(); + } + } + + #handleMediaQueryChange = () => { + const wasMobile = this.#isMobile; + this.#isMobile = isMobileBreakpoint(); + + if (wasMobile !== this.#isMobile) { + const { container } = this.refs; + container.setAttribute('data-instant-transitions', ''); + + this.#clearHeightStyles(); + // Re-calculate height first so grid calculation has correct container dimensions + this.#observeContentHeight(); + this.#updateActiveTab(); + this.#setupEventListeners(); + + requestAnimationFrame(() => { + container.removeAttribute('data-instant-transitions'); + }); + } + }; + + disconnectedCallback() { + super.disconnectedCallback(); + this.#abort?.abort(); + this.#heightObserver?.disconnect(); + this.#heightObserver = null; + this.#contentObserver?.disconnect(); + this.#contentObserver = null; + this.#containerObserver?.disconnect(); + this.#containerObserver = null; + mediaQueryLarge.removeEventListener('change', this.#handleMediaQueryChange); + } + + /** + * Public method to select a slide by index + * @param {number} index + * @param {{ instant?: boolean }} [options] + */ + select(index, { instant = false } = {}) { + this.#activate(index, instant); + } + + /** + * @param {number} index + * @param {boolean} [instant] + */ + #activate(index, instant = false) { + const { container, tabs } = this.refs; + if (!tabs || index === this.#active || index < 0 || index >= tabs.length) return; + + if (instant) { + container.setAttribute('data-instant-transitions', ''); + } + + this.#active = index; + this.#updateActiveTab(); + + if (instant) { + // Double rAF to ensure layout is fully settled before re-enabling transitions + requestAnimationFrame(() => { + requestAnimationFrame(() => { + container.removeAttribute('data-instant-transitions'); + }); + }); + } + } + + #updateActiveTab() { + const { tabs, panels } = this.refs; + + for (const [i, tab] of tabs?.entries() ?? []) { + const isActive = i === this.#active; + tab.setAttribute('aria-selected', String(isActive)); + tab.setAttribute('tabindex', isActive ? '0' : '-1'); + } + + for (const [i, panel] of panels?.entries() ?? []) { + const isActive = i === this.#active; + panel.toggleAttribute('inert', !isActive); + panel.setAttribute('tabindex', isActive ? '0' : '-1'); + + const video = panel.querySelector('video'); + if (video) { + isActive ? video.play() : video.pause(); + } + } + + this.#updateGridSizes(); + } + + /** + * @param {number} [containerSize] - Override container size (used during viewport switch) + */ + #updateGridSizes(containerSize) { + const { container, tabs } = this.refs; + if (!tabs) return; + const inactiveSize = this.#inactiveSize; + const size = + containerSize ?? + (this.#isMobile ? container.getBoundingClientRect().height : container.getBoundingClientRect().width); + const activeSize = size - inactiveSize * (tabs.length - 1); + const sizes = tabs.map((_, i) => (i === this.#active ? `${activeSize}px` : `${inactiveSize}px`)); + container.style.setProperty('--active-tab', sizes.join(' ')); + } + + /** + * @param {PointerEvent} event + */ + #startDrag(event) { + if (this.#isMobile) return; + + const { tabs } = this.refs; + if (!tabs) return; + + const eventTarget = /** @type {HTMLElement} */ (event.target); + const tab = eventTarget.closest('[role="tab"]'); + if (tab) { + const i = tabs.indexOf(/** @type {HTMLElement} */ (tab)); + if (i === this.#active) return; + const target = i > this.#active ? i + 1 : i; + if (target >= tabs.length) return; + + this.#initializeDrag(event, target); + } else { + this.#initializeDrag(event); + } + } + + /** + * @param {PointerEvent} event + * @param {number} [initialTarget] + */ + #initializeDrag(event, initialTarget) { + const { container, tabs } = this.refs; + if (!tabs) return; + + // Calculate active size from container dimensions + const containerWidth = container.getBoundingClientRect().width; + const inactiveSize = this.#inactiveSize; + const activeSize = containerWidth - inactiveSize * (tabs.length - 1); + + this.#drag = { + target: initialTarget ?? -1, + start: event.clientX, + max: containerWidth * MAX_DRAG_WIDTH_RATIO, + activeSize, + left: initialTarget !== undefined ? initialTarget > this.#active : false, + }; + + const ac = new AbortController(); + const opts = { signal: ac.signal }; + + document.addEventListener('pointermove', (e) => this.#handleDrag(e), opts); + document.addEventListener('pointerup', () => this.#endDrag(ac), opts); + document.addEventListener('pointercancel', () => this.#endDrag(ac), opts); + + event.preventDefault(); + } + + /** + * @param {PointerEvent} event + */ + #handleDrag(event) { + if (!this.#drag) return; + + const { container, tabs } = this.refs; + if (!container || !tabs) return; + + const delta = event.clientX - this.#drag.start; + const move = Math.abs(delta); + + if (!this.#drag.dragging && move >= DRAG_THRESHOLD) { + if (this.#drag.target === -1) { + if (delta > 0 && this.#active > 0) { + this.#drag.target = this.#active - 1; + this.#drag.left = false; + } else if (delta < 0 && this.#active < tabs.length - 1) { + this.#drag.target = this.#active + 1; + this.#drag.left = true; + } else { + return; + } + } + this.#drag.dragging = true; + container.setAttribute('data-dragging', ''); + } + + if (!this.#drag.dragging) return; + + const correct = this.#drag.left ? delta < 0 : delta > 0; + const progress = correct ? Math.min(move / this.#drag.max, 1) : 0; + + const inactiveSize = this.#inactiveSize; + const activeSize = this.#drag.activeSize; + const range = activeSize - inactiveSize; + const sizes = tabs.map((_, i) => { + if (i === this.#active) { + const active = Math.max(inactiveSize, activeSize - range * progress); + return `${active}px`; + } + if (i === this.#drag?.target) { + const drag = inactiveSize + range * progress; + return `${drag}px`; + } + return `${inactiveSize}px`; + }); + + container.style.setProperty('--active-tab', sizes.join(' ')); + this.#drag.progress = progress; + } + + /** + * @param {AbortController} ac + */ + #endDrag(ac) { + if (!this.#drag) return; + + const { container } = this.refs; + container?.removeAttribute('data-dragging'); + + if (this.#drag.dragging) { + this.#drag.prevent = true; + setTimeout(() => (this.#drag = null), 100); + + if (this.#drag.progress && this.#drag.progress >= DRAG_COMPLETE_THRESHOLD) { + this.#activate(this.#drag.target); + } else { + this.#updateActiveTab(); + } + } else { + this.#drag = null; + } + + ac.abort(); + } + + #observeContentHeight() { + const { panels } = this.refs; + + this.#heightObserver?.disconnect(); + this.#heightObserver = new ResizeObserver(() => this.#syncHeight()); + + this.#contentObserver?.disconnect(); + this.#contentObserver = new MutationObserver(() => this.#syncHeight()); + + for (const panel of panels ?? []) { + const content = panel.querySelector('.layered-slideshow__content'); + const inner = content?.querySelector('.group-block-content'); + + // Observe all relevant elements for resize + if (inner) this.#heightObserver.observe(inner); + if (content) this.#heightObserver.observe(content); + + // Observe content for DOM mutations (new blocks, text changes) + const target = inner ?? content; + if (target) { + this.#contentObserver.observe(target, { + childList: true, + subtree: true, + characterData: true, + }); + } + } + + this.#syncHeight(); + } + + #syncHeight() { + const { container } = this.refs; + const contentHeight = this.#getMaxContentHeight(); + const isAuto = container.getAttribute('size') === 'auto'; + + if (this.#isMobile) { + this.#syncMobileHeight(contentHeight, isAuto); + } else { + this.#syncDesktopHeight(contentHeight, isAuto); + } + } + + /** + * @param {number} contentHeight + * @param {boolean} isAuto + */ + #syncDesktopHeight(contentHeight, isAuto) { + const { container } = this.refs; + + if (isAuto) { + // Auto mode: fit to content height + let minHeightTemp = Math.max(contentHeight, 150); + container.style.height = `${minHeightTemp}px`; + this.style.minHeight = `${minHeightTemp}px`; + } else { + // Temporarily clear inline style to measure CSS-defined min-height + const savedMinHeight = this.style.minHeight; + this.style.minHeight = ''; + const cssMinHeight = parseFloat(getComputedStyle(this).minHeight) || 0; + this.style.minHeight = savedMinHeight; + + // Only set inline heights when content exceeds CSS min-height + if (contentHeight > cssMinHeight) { + this.style.minHeight = `${contentHeight}px`; + container.style.height = `${contentHeight}px`; + } else { + this.style.minHeight = ''; + container.style.height = ''; + } + } + } + + /** + * @param {number} contentHeight + * @param {boolean} isAuto + */ + #syncMobileHeight(contentHeight, isAuto) { + const { container, tabs } = this.refs; + if (!container || !tabs) return; + + const containerStyles = getComputedStyle(container); + const inactiveStackHeight = (tabs.length - 1) * this.#inactiveSize; + + let minPanelHeight; + + if (isAuto) { + // Auto mode: fit to content height with reasonable minimum + minPanelHeight = 150; + } else { + // CSS variable is set on component, try reading from container (inherited) or component directly + const inheritedValue = containerStyles.getPropertyValue('--layered-panel-height-mobile'); + const componentValue = getComputedStyle(this).getPropertyValue('--layered-panel-height-mobile'); + minPanelHeight = parseFloat(inheritedValue || componentValue) || 260; + } + + const requiredActiveHeight = Math.max(minPanelHeight, contentHeight); + + container.style.setProperty('--active-panel-height', `${requiredActiveHeight}px`); + container.style.height = `${requiredActiveHeight + inactiveStackHeight}px`; + } + + #clearHeightStyles() { + const { container } = this.refs; + + this.style.minHeight = ''; + container.style.height = ''; + container.style.removeProperty('--active-panel-height'); + } + + #getMaxContentHeight() { + const { panels } = this.refs; + let max = 0; + + for (const panel of panels ?? []) { + const content = panel.querySelector('.layered-slideshow__content'); + if (!content) continue; + + const inner = /** @type {HTMLElement} */ (content.querySelector('.group-block-content') ?? content); + + // Temporarily set height to auto for accurate measurement + // This is needed because height: 100% collapses when parent has no height + const savedHeight = inner.style.height; + inner.style.height = 'auto'; + + const styles = getComputedStyle(content); + const paddingTop = parseFloat(styles.paddingBlockStart || styles.paddingTop) || 0; + const paddingBottom = parseFloat(styles.paddingBlockEnd || styles.paddingBottom) || 0; + + const height = (inner.scrollHeight || 0) + paddingTop + paddingBottom; + if (height > max) max = height; + + // Restore original height + inner.style.height = savedHeight; + } + + return max; + } +} + +customElements.define('layered-slideshow-component', LayeredSlideshowComponent); diff --git a/assets/local-pickup.js b/assets/local-pickup.js new file mode 100644 index 000000000..4200446d7 --- /dev/null +++ b/assets/local-pickup.js @@ -0,0 +1,79 @@ +import { Component } from '@theme/component'; +import { morph } from '@theme/morph'; +import { ThemeEvents, VariantUpdateEvent } from '@theme/events'; + +class LocalPickup extends Component { + /** @type {AbortController | undefined} */ + #activeFetch; + + connectedCallback() { + super.connectedCallback(); + + const closestSection = this.closest(`.shopify-section, dialog`); + + /** @type {(event: VariantUpdateEvent) => void} */ + const variantUpdated = (event) => { + if (event.detail.data.newProduct) { + this.dataset.productUrl = event.detail.data.newProduct.url; + } + + const variantId = event.detail.resource ? event.detail.resource.id : null; + const variantAvailable = event.detail.resource ? event.detail.resource.available : null; + if (variantId !== this.dataset.variantId) { + if (variantId && variantAvailable) { + this.removeAttribute('hidden'); + this.dataset.variantId = variantId; + this.#fetchAvailability(variantId); + } else { + this.setAttribute('hidden', ''); + } + } + }; + + closestSection?.addEventListener(ThemeEvents.variantUpdate, variantUpdated); + + this.disconnectedCallback = () => { + closestSection?.removeEventListener(ThemeEvents.variantUpdate, variantUpdated); + }; + } + + #createAbortController() { + if (this.#activeFetch) this.#activeFetch.abort(); + this.#activeFetch = new AbortController(); + return this.#activeFetch; + } + + /** + * Fetches the availability of a variant. + * @param {string} variantId - The ID of the variant to fetch availability for. + */ + #fetchAvailability = (variantId) => { + if (!variantId) return; + + const abortController = this.#createAbortController(); + + const url = this.dataset.productUrl; + fetch(`${url}?variant=${variantId}§ion_id=${this.dataset.sectionId}`, { + signal: abortController.signal, + }) + .then((response) => response.text()) + .then((text) => { + if (abortController.signal.aborted) return; + + const html = new DOMParser().parseFromString(text, 'text/html'); + const wrapper = html.querySelector(`local-pickup[data-variant-id="${variantId}"]`); + if (wrapper) { + this.removeAttribute('hidden'); + morph(this, wrapper); + } else this.setAttribute('hidden', ''); + }) + .catch((_e) => { + if (abortController.signal.aborted) return; + this.setAttribute('hidden', ''); + }); + }; +} + +if (!customElements.get('local-pickup')) { + customElements.define('local-pickup', LocalPickup); +} diff --git a/assets/localization.js b/assets/localization.js new file mode 100644 index 000000000..6d47cbb96 --- /dev/null +++ b/assets/localization.js @@ -0,0 +1,552 @@ +import { Component } from '@theme/component'; +import { isClickedOutside, normalizeString, onAnimationEnd } from '@theme/utilities'; + +/** + * A custom element that displays a localization form. + * + * @typedef {object} FormRefs + * @property {HTMLDivElement} countryList - The country list element. + * @property {HTMLInputElement} countryInput - The country input element. + * @property {HTMLUListElement[]} countryListItems - The country list items element. + * @property {HTMLFormElement} form - The form element. + * @property {HTMLDivElement} liveRegion - The live region element. + * @property {HTMLSelectElement} languageInput - The language input element. + * @property {HTMLSpanElement} noResultsMessage - The no results message element. + * @property {HTMLUListElement} popularCountries - The popular countries element. + * @property {HTMLInputElement} search - The search input element. + * @property {HTMLButtonElement} resetButton - The reset button element. + * + * @extends {Component} + */ +class LocalizationFormComponent extends Component { + connectedCallback() { + super.connectedCallback(); + + this.refs.search && this.refs.search.addEventListener('keydown', this.#onSearchKeyDown); + this.refs.countryList && this.refs.countryList.addEventListener('keydown', this.#onContainerKeyDown); + this.refs.countryList && this.refs.countryList.addEventListener('scroll', this.#onCountryListScroll); + + // Resizing the language input can be expensive for browsers that don't support field-sizing: content. + // Spliting it into separate tasks at least helps when there are multiple localization forms on the page. + setTimeout(() => this.resizeLanguageInput(), 0); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.refs.search && this.refs.search.removeEventListener('keydown', this.#onSearchKeyDown); + this.refs.countryList && this.refs.countryList.removeEventListener('keydown', this.#onContainerKeyDown); + this.refs.countryList && this.refs.countryList.removeEventListener('scroll', this.#onCountryListScroll); + } + + /** + * Handles the keydown event for the container. + * + * @param {KeyboardEvent} event - The event object. + */ + #onContainerKeyDown = (event) => { + const { countryInput, countryListItems, form } = this.refs; + + switch (event.key) { + case 'ArrowUp': + event.preventDefault(); + event.stopPropagation(); + this.#changeCountryFocus('UP'); + break; + case 'ArrowDown': + event.preventDefault(); + event.stopPropagation(); + this.#changeCountryFocus('DOWN'); + break; + case 'Enter': { + event.preventDefault(); + event.stopPropagation(); + const focusedItem = countryListItems.find((item) => item.getAttribute('aria-selected') === 'true'); + + if (focusedItem) { + countryInput.value = focusedItem.dataset.value ?? ''; + form.submit(); + } + break; + } + } + + if (!this.refs.search) return; + + setTimeout(() => { + const focusableItems = this.refs.countryListItems.filter((item) => !item.hasAttribute('hidden')); + const focusedItemIndex = focusableItems.findIndex((item) => item === document.activeElement); + const focusedItem = focusableItems[focusedItemIndex]; + + if (focusedItem) { + this.refs.search.setAttribute('aria-activedescendant', focusedItem.id); + } else { + this.refs.search.setAttribute('aria-activedescendant', ''); + } + }); + }; + + /** + * Selects a country. + * + * @param {string} countryName - The name of the country to select. + * @param {Event} event - The event object. + */ + selectCountry = (countryName, event) => { + event.preventDefault(); + const { countryInput, form } = this.refs; + + countryInput.value = countryName; + form?.submit(); + }; + + /** + * Changes the language of the localization form. + * + * @param {Event} event - The event object. + */ + changeLanguage(event) { + const { form, languageInput } = this.refs; + const value = event.target instanceof HTMLSelectElement ? event.target.value : null; + + if (value) { + languageInput.value = value; + this.resizeLanguageInput(); + form.submit(); + } + } + + resizeLanguageInput() { + const { languageInput } = this.refs; + + if (!languageInput || CSS.supports('field-sizing', 'content')) return; + + // Hide all options except the selected option + for (const option of languageInput.options) { + if (!option.selected) { + option.dataset.optionLabel = option.textContent || ''; + option.innerText = ''; + } + } + + // Calculate the width of the select element (which is based on the width of the widest option) + languageInput.style.width = 'fit-content'; + const originalElementWidth = `${Math.ceil(languageInput.offsetWidth) + 1}px`; + + // Fix the width of the select element + if (languageInput.offsetWidth > 0) { + languageInput.style.width = originalElementWidth; + } + + // Add back all option labels + for (const option of languageInput.options) { + if (option.dataset.optionLabel) { + option.textContent = option.dataset.optionLabel; + delete option.dataset.optionLabel; + } + } + } + + /** + * Finds matches for a given search value in a country element. + * + * @typedef {Object} Options + * @property {boolean} [matchLabel] - Whether to match the label. + * @property {boolean} [matchAlias] - Whether to match the alias. + * @property {boolean} [matchIso] - Whether to match the iso. + * @property {boolean} [matchCurrency] - Whether to match the currency. + * @property {boolean} [labelMatchStart] - Whether to match the label start. + * @property {boolean} [aliasExactMatch] - Whether to match the alias exact match. + * + * @typedef {Object} MatchTypes + * @property {boolean} [label] - Whether the label matches the search value. + * @property {boolean} [alias] - Whether the alias matches the search value. + * @property {boolean} [iso] - Whether the iso matches the search value. + * @property {boolean} [currency] - Whether the currency matches the search value. + * + * @param {string} searchValue - The search value to find matches for. + * @param {HTMLElement} countryEl - The country element to find matches in. + * @param {Options} options - The options for the search. + * @returns {MatchTypes} The matches found in the country element. + */ + #findMatches( + searchValue, + countryEl, + options = { + // Which data types (label, alias, iso) to match against + matchLabel: true, + matchAlias: true, + matchIso: true, + matchCurrency: true, + // If true, the search value must match the start of the label + labelMatchStart: false, + // If true, a result will not display unless the search value equals an alias in its entirety + aliasExactMatch: false, + } + ) { + let matchTypes = {}; + const { aliases, value: iso } = countryEl.dataset; + + if (options.matchLabel) { + const countryName = normalizeString(countryEl.querySelector('.country')?.textContent ?? ''); + + if (!countryName) return matchTypes; + + matchTypes.label = options.labelMatchStart + ? countryName.startsWith(searchValue) + : countryName.includes(searchValue); + } + + if (options.matchCurrency) { + const currency = normalizeString(countryEl.querySelector('.localization-form__currency')?.textContent ?? ''); + matchTypes.currency = currency.includes(searchValue); + } + + if (options.matchIso) { + matchTypes.iso = normalizeString(iso ?? '') == searchValue; + } + + if (options.matchAlias) { + const countryAliases = aliases?.split(',').map((alias) => normalizeString(alias)); + + if (!countryAliases) return matchTypes; + + matchTypes.alias = + countryAliases.length > 0 && + countryAliases.find((alias) => + options.aliasExactMatch ? alias === searchValue : alias.startsWith(searchValue) + ) !== undefined; + } + + return matchTypes; + } + + /** + * Highlights matching text in a string by wrapping it in tags. + * + * @param {string | null} text - The text to highlight. + * @param {string} searchValue - The search value to highlight. + * @returns {string} The text with matching parts wrapped in tags. + */ + #highlightMatches(text, searchValue) { + if (!text || !searchValue) return text ?? ''; + + const normalizedText = normalizeString(text); + const normalizedSearch = normalizeString(searchValue); + const startIndex = normalizedText.indexOf(normalizedSearch); + + if (startIndex === -1) return text; + + const endIndex = startIndex + normalizedSearch.length; + const before = text.slice(0, startIndex); + const match = text.slice(startIndex, endIndex); + const after = text.slice(endIndex); + + let result = ''; + if (before) { + result += `${before}`; + } + result += match; + if (after) { + result += `${after}`; + } + return result; + } + + /** + * Filters the countries based on the search value. + */ + filterCountries() { + const { countryList, countryListItems, liveRegion, noResultsMessage, popularCountries, resetButton, search } = + this.refs; + const { labelResultsCount } = this.dataset; + const searchValue = normalizeString(search.value); + let countVisibleCountries = 0; + + resetButton.toggleAttribute('hidden', !searchValue); + + if (popularCountries) { + popularCountries.toggleAttribute('hidden', Boolean(searchValue)); + } + + const wrapper = this.querySelector('.country-selector-form__wrapper'); + if (wrapper) { + wrapper.classList.toggle('is-searching', !!searchValue); + } + + for (const countryEl of countryListItems) { + if (searchValue === '') { + countryEl.removeAttribute('hidden'); + const countrySpan = countryEl.querySelector('.country'); + if (countrySpan) { + // eslint-disable-next-line no-self-assign + countrySpan.textContent = countrySpan.textContent; + } + countVisibleCountries++; + } else { + const matches = this.#findMatches(searchValue, countryEl); + + // In the future, we could reorder/rank filtered results based on the match types + if (matches.label || matches.alias || matches.iso || matches.currency) { + countryEl.removeAttribute('hidden'); + const countrySpan = countryEl.querySelector('.country'); + if (countrySpan) { + countrySpan.innerHTML = this.#highlightMatches(countrySpan.textContent, searchValue); + } + countVisibleCountries++; + } else { + countryEl.setAttribute('hidden', ''); + } + } + } + + if (liveRegion && labelResultsCount) { + liveRegion.innerText = labelResultsCount.replace('[count]', `${countVisibleCountries}`); + } + + noResultsMessage.hidden = countVisibleCountries > 0; + countryList.scrollTop = 0; + } + + /** + * Changes the focus of the country list items. + * + * @param {string} direction - The direction to change the focus. + */ + #changeCountryFocus(direction) { + const { countryListItems } = this.refs; + const focusableItems = countryListItems.filter((item) => !item.hasAttribute('hidden')); + const focusedItemIndex = focusableItems.findIndex((item) => item === document.activeElement); + const focusedItem = focusableItems[focusedItemIndex]; + let itemToFocus; + + if (direction === 'UP') { + itemToFocus = + focusedItemIndex > 0 ? focusableItems[focusedItemIndex - 1] : focusableItems[focusableItems.length - 1]; + } else { + itemToFocus = + focusedItemIndex < focusableItems.length - 1 ? focusableItems[focusedItemIndex + 1] : focusableItems[0]; + } + + if (focusedItem) { + focusedItem.setAttribute('aria-selected', 'false'); + } + itemToFocus?.setAttribute('aria-selected', 'true'); + itemToFocus?.focus(); + } + + /** + * Resets the countries filter. + * + * @param {Event} event - The event object. + */ + resetCountriesFilter(event) { + const { search } = this.refs; + + event.stopPropagation(); + search.value = ''; + this.filterCountries(); + search.setAttribute('aria-activedescendant', ''); + search.focus(); + } + + /** + * Handles the keydown event for the search input. + * + * @param {KeyboardEvent} event - The event object. + */ + #onSearchKeyDown = (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + return; + } + this.#onContainerKeyDown(event); + }; + + /** + * Resets the form. + */ + resetForm() { + const { search } = this.refs; + + if (!search) return; + + if (search.value != '') { + search.value = ''; + this.filterCountries(); + search.setAttribute('aria-activedescendant', ''); + } + } + + /** + * Focuses the search input. + */ + focusSearchInput = () => { + const { search } = this.refs; + + search?.focus(); + }; + + /** + * Handles the scroll event on the country list. + * + * @param {Event} event - The scroll event object. + */ + #onCountryListScroll = (event) => { + const countryFilter = this.querySelector('.country-filter'); + const countryList = event.target instanceof HTMLElement ? event.target : null; + + if (countryFilter && countryList) { + const shouldShowBorder = countryList.scrollTop > 0; + countryFilter.classList.toggle('is-scrolled', shouldShowBorder); + } + }; +} + +/** + * A custom element that displays a dropdown localization form. + * + * @typedef {object} DropdownRefs + * @property {HTMLButtonElement} button - The button element. + * @property {HTMLDivElement} panel - The panel element. + * @property {LocalizationFormComponent} localizationForm - The localization form component. + * + * @extends {Component} + */ +class DropdownLocalizationComponent extends Component { + get isHidden() { + return this.refs.panel.hasAttribute('hidden'); + } + + /** + * Toggles the panel. + */ + toggleSelector() { + return this.isHidden ? this.showPanel() : this.hidePanel(); + } + + /** + * Shows the panel. + */ + showPanel() { + if (!this.isHidden) return; + + this.addEventListener('keyup', this.#handleKeyUp); + document.addEventListener('click', this.#handleClickOutside); + + this.refs.panel.removeAttribute('hidden'); + this.refs.button.setAttribute('aria-expanded', 'true'); + + onAnimationEnd(this.refs.panel, () => { + this.#updateWidth(); + this.refs.localizationForm?.focusSearchInput(); + }); + } + + /** + * Hides the panel. + */ + hidePanel = () => { + if (this.isHidden) return; + + this.removeEventListener('keyup', this.#handleKeyUp); + document.removeEventListener('click', this.#handleClickOutside); + + this.refs.button?.setAttribute('aria-expanded', 'false'); + this.refs.panel.setAttribute('hidden', ''); + this.refs.localizationForm?.resetForm(); + }; + + /** + * Handles the click outside event. + * + * @param {PointerEvent} event - The event object. + */ + #handleClickOutside = (event) => { + if (isClickedOutside(event, this)) { + this.hidePanel(); + } + }; + + /** + * Updates the width of the panel. + */ + #updateWidth() { + this.style.setProperty('--width', `${this.refs.localizationForm.offsetWidth}px`); + } + + /** + * Handles the keyup event. + * + * @param {KeyboardEvent} event - The event object. + */ + #handleKeyUp = (event) => { + switch (event.key) { + case 'Escape': + this.hidePanel(); + event.stopPropagation(); + this.refs.button?.focus(); + break; + } + }; +} + +/** + * A custom element that displays a drawer localization form. + * + * @typedef {object} DrawerRefs + * @property {HTMLDialogElement} dialog - The dialog element. + * @property {LocalizationFormComponent} localizationForm - The localization form component. + * + * @extends {Component} + */ +class DrawerLocalizationComponent extends Component { + /** + * Toggles the dialog. + * + * @param {ToggleEvent} event - The event object. + */ + toggle(event) { + const { target } = event; + const { localizationForm } = this.refs; + + if (!localizationForm || !(target instanceof HTMLDetailsElement)) return; + + const countryList = localizationForm.querySelector('.country-selector-form__wrapper'); + + if (target.open) { + if (countryList) countryList.addEventListener('scroll', this.#onCountryListScroll); + onAnimationEnd(target, localizationForm.focusSearchInput); + } else { + countryList?.removeEventListener('scroll', this.#onCountryListScroll); + localizationForm.resetForm(); + } + } + + /** + * Handles the scroll event on the country list. + * + * @param {Event} event - The scroll event object. + */ + #onCountryListScroll = (event) => { + const countryFilter = this.querySelector('.country-filter'); + const countryList = event.target instanceof HTMLElement ? event.target : null; + + if (countryFilter && countryList) { + const shouldShowBorder = countryList.scrollTop > 0; + countryFilter.classList.toggle('is-scrolled', shouldShowBorder); + } + }; +} + +if (!customElements.get('localization-form-component')) { + customElements.define('localization-form-component', LocalizationFormComponent); +} + +if (!customElements.get('dropdown-localization-component')) { + customElements.define('dropdown-localization-component', DropdownLocalizationComponent); +} + +if (!customElements.get('drawer-localization-component')) { + customElements.define('drawer-localization-component', DrawerLocalizationComponent); +} diff --git a/assets/marquee.js b/assets/marquee.js new file mode 100644 index 000000000..94b2961ab --- /dev/null +++ b/assets/marquee.js @@ -0,0 +1,276 @@ +import { Component } from '@theme/component'; +import { debounce } from '@theme/utilities'; + +const ANIMATION_OPTIONS = { + duration: 500, +}; + +/** + * A custom element that displays a marquee. + * + * @typedef {object} Refs + * @property {HTMLElement} wrapper - The wrapper element. + * @property {HTMLElement} content - The content element. + * @property {HTMLElement[]} marqueeItems - The marquee items collection. + * + * @extends Component + */ +class MarqueeComponent extends Component { + requiredRefs = ['wrapper', 'content', 'marqueeItems']; + + async connectedCallback() { + super.connectedCallback(); + + const { marqueeItems } = this.refs; + if (marqueeItems.length === 0) return; + + const { numberOfCopies } = await this.#queryNumberOfCopies(); + + const speed = this.#calculateSpeed(numberOfCopies); + + this.#addRepeatedItems(numberOfCopies); + this.#duplicateContent(); + + this.#setSpeed(speed); + + window.addEventListener('resize', this.#handleResize); + this.addEventListener('pointerenter', this.#slowDown); + this.addEventListener('pointerleave', this.#speedUp); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('resize', this.#handleResize); + this.removeEventListener('pointerenter', this.#slowDown); + this.removeEventListener('pointerleave', this.#speedUp); + } + + /** + * @type {{ cancel: () => void, current: number } | null} + */ + #animation = null; + + /** + * @type {number | null} + */ + #marqueeWidth = null; + + #slowDown = debounce(() => { + if (this.#animation) return; + + const animation = this.refs.wrapper.getAnimations()[0]; + + if (!animation) return; + + this.#animation = animateValue({ + ...ANIMATION_OPTIONS, + from: 1, + to: 0, + onUpdate: (value) => animation.updatePlaybackRate(value), + onComplete: () => { + this.#animation = null; + }, + }); + }, ANIMATION_OPTIONS.duration); + + #speedUp() { + this.#slowDown.cancel(); + + const animation = this.refs.wrapper.getAnimations()[0]; + + if (!animation || animation.playbackRate === 1) return; + + const from = this.#animation?.current ?? 0; + this.#animation?.cancel(); + + this.#animation = animateValue({ + ...ANIMATION_OPTIONS, + from, + to: 1, + onUpdate: (value) => animation.updatePlaybackRate(value), + onComplete: () => { + this.#animation = null; + }, + }); + } + + get clonedContent() { + const { content, wrapper } = this.refs; + const lastChild = wrapper.lastElementChild; + + return content !== lastChild ? lastChild : null; + } + + /** + * @param {number} value + */ + #setSpeed(value) { + this.style.setProperty('--marquee-speed', `${value}s`); + } + + async #queryNumberOfCopies() { + const { marqueeItems } = this.refs; + + return new Promise((resolve) => { + if (!marqueeItems[0]) { + // Wrapping the resolve in a setTimeout here and below splits each marquee reflow into a separate task. + return setTimeout(() => resolve({ numberOfCopies: 1, isHorizontalResize: true }), 0); + } + + const intersectionObserver = new IntersectionObserver( + (entries) => { + const firstEntry = entries[0]; + if (!firstEntry) return; + intersectionObserver.disconnect(); + + const { width: marqueeWidth } = firstEntry.rootBounds ?? { width: 0 }; + const { width: marqueeItemsWidth } = firstEntry.boundingClientRect; + + const isHorizontalResize = this.#marqueeWidth !== marqueeWidth; + this.#marqueeWidth = marqueeWidth; + + setTimeout(() => { + resolve({ + numberOfCopies: marqueeItemsWidth === 0 ? 1 : Math.ceil(marqueeWidth / marqueeItemsWidth), + isHorizontalResize, + }); + }, 0); + }, + { root: this } + ); + intersectionObserver.observe(marqueeItems[0]); + }); + } + + /** + * @param {number} numberOfCopies + */ + #calculateSpeed(numberOfCopies) { + const speedFactor = Number(this.getAttribute('data-speed-factor')); + const speed = Math.sqrt(numberOfCopies) * speedFactor; + + return speed; + } + + #handleResize = debounce(async () => { + const { marqueeItems } = this.refs; + const { newNumberOfCopies, isHorizontalResize } = await this.#queryNumberOfCopies(); + + // opt out of marquee manipulation on vertical resizes + if (!isHorizontalResize) return; + + const currentNumberOfCopies = marqueeItems.length; + const speed = this.#calculateSpeed(newNumberOfCopies); + + if (newNumberOfCopies > currentNumberOfCopies) { + this.#addRepeatedItems(newNumberOfCopies - currentNumberOfCopies); + } else if (newNumberOfCopies < currentNumberOfCopies) { + this.#removeRepeatedItems(currentNumberOfCopies - newNumberOfCopies); + } + + this.#duplicateContent(); + this.#setSpeed(speed); + this.#restartAnimation(); + }, 250); + + #restartAnimation() { + const animations = this.refs.wrapper.getAnimations(); + + requestAnimationFrame(() => { + for (const animation of animations) { + animation.currentTime = 0; + } + }); + } + + #duplicateContent() { + this.clonedContent?.remove(); + + const clone = /** @type {HTMLElement} */ (this.refs.content.cloneNode(true)); + + clone.setAttribute('aria-hidden', 'true'); + clone.removeAttribute('ref'); + + this.refs.wrapper.appendChild(clone); + } + + /** + * @param {number} numberOfCopies + */ + #addRepeatedItems(numberOfCopies) { + const { content, marqueeItems } = this.refs; + + if (!marqueeItems[0]) return; + + for (let i = 0; i < numberOfCopies - 1; i++) { + const clone = marqueeItems[0].cloneNode(true); + content.appendChild(clone); + } + } + + /** + * @param {number} numberOfCopies + */ + #removeRepeatedItems(numberOfCopies) { + const { content } = this.refs; + const children = Array.from(content.children); + + const itemsToRemove = Math.min(numberOfCopies, children.length - 1); + + for (let i = 0; i < itemsToRemove; i++) { + content.lastElementChild?.remove(); + } + } +} + +// Define the animateValue function +/** + * Animate a numeric property smoothly. + * @param {Object} params - The parameters for the animation. + * @param {number} params.from - The starting value. + * @param {number} params.to - The ending value. + * @param {number} params.duration - The duration of the animation in milliseconds. + * @param {function(number): void} params.onUpdate - The function to call on each update. + * @param {function(number): number} [params.easing] - The easing function. + * @param {function(): void} [params.onComplete] - The function to call when the animation completes. + */ +function animateValue({ from, to, duration, onUpdate, easing = (t) => t * t * (3 - 2 * t), onComplete }) { + const startTime = performance.now(); + let cancelled = false; + let currentValue = from; + + /** + * @param {number} currentTime - The current time in milliseconds. + */ + function animate(currentTime) { + if (cancelled) return; + + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easing(progress); + currentValue = from + (to - from) * easedProgress; + + onUpdate(currentValue); + + if (progress < 1) { + requestAnimationFrame(animate); + } else if (typeof onComplete === 'function') { + onComplete(); + } + } + + requestAnimationFrame(animate); + + return { + get current() { + return currentValue; + }, + cancel() { + cancelled = true; + }, + }; +} + +if (!customElements.get('marquee-component')) { + customElements.define('marquee-component', MarqueeComponent); +} diff --git a/assets/media-gallery.js b/assets/media-gallery.js new file mode 100644 index 000000000..b775648f2 --- /dev/null +++ b/assets/media-gallery.js @@ -0,0 +1,95 @@ +import { Component } from '@theme/component'; +import { ThemeEvents, VariantUpdateEvent, ZoomMediaSelectedEvent } from '@theme/events'; + +/** + * A custom element that renders a media gallery. + * + * @typedef {object} Refs + * @property {import('./zoom-dialog').ZoomDialog} [zoomDialogComponent] - The zoom dialog component. + * @property {import('./slideshow').Slideshow} [slideshow] - The slideshow component. + * @property {HTMLElement[]} [media] - The media elements. + * + * @extends Component + */ +export class MediaGallery extends Component { + connectedCallback() { + super.connectedCallback(); + + const { signal } = this.#controller; + const target = this.closest('.shopify-section, dialog'); + + target?.addEventListener(ThemeEvents.variantUpdate, this.#handleVariantUpdate, { signal }); + this.refs.zoomDialogComponent?.addEventListener(ThemeEvents.zoomMediaSelected, this.#handleZoomMediaSelected, { + signal, + }); + } + + #controller = new AbortController(); + + disconnectedCallback() { + super.disconnectedCallback(); + + this.#controller.abort(); + } + + /** + * Handles a variant update event by replacing the current media gallery with a new one. + * + * @param {VariantUpdateEvent} event - The variant update event. + */ + #handleVariantUpdate = (event) => { + const source = event.detail.data.html; + + if (!source) return; + const newMediaGallery = source.querySelector('media-gallery'); + + if (!newMediaGallery) return; + + this.replaceWith(newMediaGallery); + }; + + /** + * Handles the 'zoom-media:selected' event. + * @param {ZoomMediaSelectedEvent} event - The zoom-media:selected event. + */ + #handleZoomMediaSelected = async (event) => { + this.slideshow?.select(event.detail.index, undefined, { animate: false }); + }; + + /** + * Zooms the media gallery. + * + * @param {number} index - The index of the media to zoom. + * @param {PointerEvent} event - The pointer event. + */ + zoom(index, event) { + this.refs.zoomDialogComponent?.open(index, event); + } + + /** + * Preloads an image. + * @param {number} index - The index of the media to preload. + */ + preloadImage(index) { + const zoomDialogMedia = this.refs.zoomDialogComponent?.refs.media[index]; + if (!zoomDialogMedia) return; + + this.refs.zoomDialogComponent?.loadHighResolutionImage(zoomDialogMedia); + } + + get slideshow() { + return this.refs.slideshow; + } + + get media() { + return this.refs.media; + } + + get presentation() { + return this.dataset.presentation; + } +} + +if (!customElements.get('media-gallery')) { + customElements.define('media-gallery', MediaGallery); +} diff --git a/assets/media.js b/assets/media.js new file mode 100644 index 000000000..cb10bd14e --- /dev/null +++ b/assets/media.js @@ -0,0 +1,248 @@ +import { Component } from '@theme/component'; +import { ThemeEvents, MediaStartedPlayingEvent } from '@theme/events'; +import { DialogCloseEvent } from '@theme/dialog'; + +/** + * A deferred media element + * @typedef {Object} Refs + * @property {HTMLElement} deferredMediaPlayButton - The button to show the deferred media content + * @property {HTMLElement} toggleMediaButton - The button to toggle the media + * + * @extends {Component} + */ +class DeferredMedia extends Component { + /** @type {boolean} */ + isPlaying = false; + + #abortController = new AbortController(); + + connectedCallback() { + super.connectedCallback(); + const signal = this.#abortController.signal; + // If we're to use deferred media for images, we will need to run this only when it's not an image type media + document.addEventListener(ThemeEvents.mediaStartedPlaying, this.pauseMedia.bind(this), { signal }); + window.addEventListener(DialogCloseEvent.eventName, this.pauseMedia.bind(this), { signal }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#abortController.abort(); + } + + /** + * Updates the visual hint for play/pause state + * @param {boolean} isPlaying - Whether the video is currently playing + */ + updatePlayPauseHint(isPlaying) { + const toggleMediaButton = this.refs.toggleMediaButton; + if (toggleMediaButton instanceof HTMLElement) { + toggleMediaButton.classList.remove('hidden'); + const playIcon = toggleMediaButton.querySelector('.icon-play'); + if (playIcon) playIcon.classList.toggle('hidden', isPlaying); + const pauseIcon = toggleMediaButton.querySelector('.icon-pause'); + if (pauseIcon) pauseIcon.classList.toggle('hidden', !isPlaying); + } + } + + /** + * Shows the deferred media content + */ + showDeferredMedia = () => { + this.loadContent(true); + this.isPlaying = true; + this.updatePlayPauseHint(this.isPlaying); + }; + + /** + * Loads the content + * @param {boolean} [focus] - Whether to focus the content + */ + loadContent(focus = true) { + if (this.getAttribute('data-media-loaded')) return; + + this.dispatchEvent(new MediaStartedPlayingEvent(this)); + + const content = this.querySelector('template')?.content.firstElementChild?.cloneNode(true); + + if (!content) return; + + this.setAttribute('data-media-loaded', 'true'); + this.appendChild(content); + + if (focus && content instanceof HTMLElement) { + content.focus(); + } + + this.refs.deferredMediaPlayButton?.classList.add('deferred-media__playing'); + + if (content instanceof HTMLVideoElement && content.getAttribute('autoplay')) { + // force autoplay for safari + content.play(); + } + } + + /** + * Toggle play/pause state of the media + */ + toggleMedia() { + if (this.isPlaying) { + this.pauseMedia(); + } else { + this.playMedia(); + } + } + + playMedia() { + /** @type {HTMLIFrameElement | null} */ + const iframe = this.querySelector('iframe[data-video-type]'); + if (iframe) { + iframe.contentWindow?.postMessage( + iframe.dataset.videoType === 'youtube' + ? '{"event":"command","func":"playVideo","args":""}' + : '{"method":"play"}', + '*' + ); + } else { + this.querySelector('video')?.play(); + } + this.isPlaying = true; + this.updatePlayPauseHint(this.isPlaying); + } + + /** + * Pauses the media + */ + pauseMedia() { + /** @type {HTMLIFrameElement | null} */ + const iframe = this.querySelector('iframe[data-video-type]'); + + if (iframe) { + iframe.contentWindow?.postMessage( + iframe.dataset.videoType === 'youtube' + ? '{"event":"command","func":"' + 'pauseVideo' + '","args":""}' + : '{"method":"pause"}', + '*' + ); + } else { + this.querySelector('video')?.pause(); + } + this.isPlaying = false; + + // If we've already revealed the deferred media, we should toggle the play/pause hint + if (this.getAttribute('data-media-loaded')) { + this.updatePlayPauseHint(this.isPlaying); + } + } +} + +if (!customElements.get('deferred-media')) { + customElements.define('deferred-media', DeferredMedia); +} + +/** + * A product model + */ +class ProductModel extends DeferredMedia { + #abortController = new AbortController(); + + loadContent() { + super.loadContent(); + + Shopify.loadFeatures([ + { + name: 'model-viewer-ui', + version: '1.0', + onLoad: this.setupModelViewerUI.bind(this), + }, + ]); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#abortController.abort(); + } + + pauseMedia() { + super.pauseMedia(); + this.modelViewerUI?.pause(); + } + + playMedia() { + super.playMedia(); + this.modelViewerUI?.play(); + } + + /** + * @param {Error[]} errors + */ + async setupModelViewerUI(errors) { + if (errors) return; + + if (!Shopify.ModelViewerUI) { + await this.#waitForModelViewerUI(); + } + + if (!Shopify.ModelViewerUI) return; + + const element = this.querySelector('model-viewer'); + if (!element) return; + + const signal = this.#abortController.signal; + + this.modelViewerUI = new Shopify.ModelViewerUI(element); + if (!this.modelViewerUI) return; + + this.playMedia(); + + // Track pointer events to detect taps + let pointerStartX = 0; + let pointerStartY = 0; + + element.addEventListener( + 'pointerdown', + (/** @type {PointerEvent} */ event) => { + pointerStartX = event.clientX; + pointerStartY = event.clientY; + }, + { signal } + ); + + element.addEventListener( + 'click', + (/** @type {PointerEvent} */ event) => { + const distanceX = Math.abs(event.clientX - pointerStartX); + const distanceY = Math.abs(event.clientY - pointerStartY); + const totalDistance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); + + // Try to ensure that this is a tap, not a drag. + if (totalDistance < 10) { + // When the model is paused, it has its own button overlay for playing the model again. + // If we're receiving a click event, it means the model is playing, all we can do is pause it. + this.pauseMedia(); + } + }, + { signal } + ); + } + + /** + * Waits for Shopify.ModelViewerUI to be defined. + * This seems to be necessary for Safari since Shopify.ModelViewerUI is always undefined on the first try. + * @returns {Promise} + */ + async #waitForModelViewerUI() { + const maxAttempts = 10; + const interval = 50; + + for (let i = 0; i < maxAttempts; i++) { + if (Shopify.ModelViewerUI) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + } +} + +if (!customElements.get('product-model')) { + customElements.define('product-model', ProductModel); +} diff --git a/assets/money-formatting.js b/assets/money-formatting.js new file mode 100644 index 000000000..c324c51f6 --- /dev/null +++ b/assets/money-formatting.js @@ -0,0 +1,206 @@ +/** + * Money formatting utilities to replicate Shopify's `money` liquid filter client-side. + * Using server-side output for money formatting is preferred, like fetching HTML responses from the Section Rendering API. + * These utilities are intended for cases where UI needs to be updated in real-time, like a price filter state change while the user is typing. + * @module money-formatting + */ + +/** + * Default currency decimals used in most currencies + * @constant {number} + */ +const DEFAULT_CURRENCY_DECIMALS = 2; + +/** + * Decimal precision for currencies that have a non-default precision + * @type {Record} + */ +const CURRENCY_DECIMALS = { + BHD: 3, + BIF: 0, + BYR: 0, + CLF: 4, + CLP: 0, + DJF: 0, + GNF: 0, + IQD: 3, + ISK: 0, + JOD: 3, + JPY: 0, + KMF: 0, + KRW: 0, + KWD: 3, + LYD: 3, + MRO: 5, + OMR: 3, + PYG: 0, + RWF: 0, + TND: 3, + UGX: 0, + UYI: 0, + UYW: 4, + VND: 0, + VUV: 0, + XAF: 0, + XAG: 0, + XAU: 0, + XBA: 0, + XBB: 0, + XBC: 0, + XBD: 0, + XDR: 0, + XOF: 0, + XPD: 0, + XPF: 0, + XPT: 0, + XSU: 0, + XTS: 0, + XUA: 0, +}; + +/** + * Parses a money string into minor units (the smallest denomination of a currency). + * Does not assume the money string is formatted in a specific way, aims to be resilient to user input. + * Example: convertMoneyToMinorUnits("1.000,50", "EUR") → 100050 + * Example: convertMoneyToMinorUnits("1 000.50", "EUR") → 100050 + * Minor units are cents for USD/EUR (100 cents = $1), yen for JPY (no subdivision), + * or fils for KWD (1000 fils = 1 dinar). This allows precise integer arithmetic. + * Handles multiple formats: US (1,000.50), European (1.000,50), and multi-separator (2,000,000.50). + * @param {string} value - The string value to parse + * @param {string} currency - The currency code + * @returns {number|null} The value in minor units, or null if parsing failed + */ +export function convertMoneyToMinorUnits(value, currency) { + const precision = CURRENCY_DECIMALS[currency.toUpperCase()] ?? DEFAULT_CURRENCY_DECIMALS; + const multiplier = Math.pow(10, precision); + + if (!value || !value.trim()) { + return null; + } + + // Split on non-digit characters to handle both . and , as decimal separators + const parts = value + .trim() + .split(/[^0-9]/) + .filter(Boolean); + + if (parts.length === 0) return null; + + // Determine if the last segment is a decimal portion: + // - For currencies with decimals (precision > 0), if last segment has digits <= precision, it's likely a decimal + // - For zero-decimal currencies (JPY), or if last segment has more digits than precision, it's a thousands separator + // Examples: "2,000,000.50" USD → ["2","000","000","50"] → last "50" (2 ≤ 2) = decimal + // "2,000,000" USD → ["2","000","000"] → last "000" (3 > 2) = thousands + // "9,500" KWD (3 dec) → ["9","500"] → last "500" (3 ≤ 3) = decimal + const lastPart = parts[parts.length - 1] ?? ''; + const lastPartIsDecimal = precision > 0 && parts.length > 1 && lastPart.length <= precision; + + let wholeStr, fractionStr; + + if (lastPartIsDecimal) { + // Last part is decimal, everything else is the whole number + fractionStr = lastPart; + wholeStr = parts.slice(0, -1).join(''); + } else { + // All parts are the whole number (no decimal portion) + wholeStr = parts.join(''); + fractionStr = ''; + } + + const whole = parseInt(wholeStr, 10); + if (isNaN(whole)) return null; + + let fraction = 0; + + if (precision > 0 && fractionStr) { + const fractionStrLength = fractionStr.length; + fraction = parseInt(fractionStr, 10) || 0; + fraction = fraction * Math.pow(10, precision - fractionStrLength); + } + + return whole * multiplier + fraction; +} + +/** + * Formats money in minor units + * @param {number} moneyValue - The money value in minor units + * @param {string} thousandsSeparator - The thousands separator + * @param {string} decimalSeparator - The decimal separator + * @param {number} precision - The display precision + * @param {number} divisor - The divisor to convert minor units to major units + * @returns {string} The formatted money value + */ +function formatCents(moneyValue, thousandsSeparator, decimalSeparator, precision, divisor) { + const roundedNumber = (moneyValue / divisor).toFixed(precision); + + let [a, b] = roundedNumber.split('.'); + if (!a) a = '0'; + if (!b) b = ''; + + // Split by groups of 3 digits + a = a.replace(/\d(?=(\d\d\d)+(?!\d))/g, (digit) => digit + thousandsSeparator); + + return precision <= 0 ? a : a + decimalSeparator + b.padEnd(precision, '0'); +} + +/** + * Formats money, replicating the implementation of the `money` liquid filters + * @param {number} moneyValue - The money value in minor units + * @param {string} format - The Shopify's money format template (e.g., '{{amount}}', '${{amount}}') + * @param {string} currency - The currency code (e.g., 'USD', 'JPY') + * @returns {string} The formatted money value + */ +export function formatMoney(moneyValue, format, currency) { + // Calculate divisor based on currency's native precision + const currencyPrecision = CURRENCY_DECIMALS[currency.toUpperCase()] ?? DEFAULT_CURRENCY_DECIMALS; + const divisor = Math.pow(10, currencyPrecision); + + return format.replace(/{{\s*(\w+)\s*}}/g, (_, placeholder) => { + if (typeof placeholder !== 'string') return ''; + if (placeholder === 'currency') return currency; + + let thousandsSeparator = ','; + let decimalSeparator = '.'; + let precision = currencyPrecision; + + switch (placeholder) { + case 'amount': + // Check first since it's the most common, use defaults. + break; + case 'amount_no_decimals': + precision = 0; + break; + case 'amount_with_comma_separator': + thousandsSeparator = '.'; + decimalSeparator = ','; + break; + case 'amount_no_decimals_with_comma_separator': + // Weirdly, this is correct. It uses amount_with_comma_separator's + // behaviour but removes decimals, resulting in an unintuitive + // output that can't possibly include commas, despite the name. + thousandsSeparator = '.'; + precision = 0; + break; + case 'amount_no_decimals_with_space_separator': + thousandsSeparator = ' '; + precision = 0; + break; + case 'amount_with_space_separator': + thousandsSeparator = ' '; + decimalSeparator = ','; + break; + case 'amount_with_period_and_space_separator': + thousandsSeparator = ' '; + decimalSeparator = '.'; + break; + case 'amount_with_apostrophe_separator': + thousandsSeparator = "'"; + decimalSeparator = '.'; + break; + default: + break; + } + + return formatCents(moneyValue, thousandsSeparator, decimalSeparator, precision, divisor); + }); +} diff --git a/assets/morph.js b/assets/morph.js new file mode 100644 index 000000000..cd6c22b25 --- /dev/null +++ b/assets/morph.js @@ -0,0 +1,578 @@ +import { Component } from '@theme/component'; + +/** + * @typedef {Object} Options + * @property {boolean} [childrenOnly] - Only update children + * @property {(node: Node | undefined) => string|number|undefined} [getNodeKey] - Get node key for matching + * @property {(oldNode: Node, newNode: Node) => void} [onBeforeUpdate] - Pre-update hook + * @property {(node: Node) => void} [onAfterUpdate] - Post-update hook + * @property {(oldNode: Node, newNode: Node) => boolean} [reject] - Reject a node from being morphed + * @property {boolean} [hydrationMode] - If true, only morph subtrees whose elements have `data-hydration-key=""`, matched by that value + */ + +const HYDRATION_KEY_ATTRIBUTE = 'data-hydration-key'; + +/** + * The options for the morph + * @type {Options} + */ +export const MORPH_OPTIONS = { + childrenOnly: true, + hydrationMode: false, + reject(oldNode, newNode) { + if (newNode.nodeType === Node.TEXT_NODE && newNode.nodeValue?.trim() === '') { + return true; + } + + if ( + newNode instanceof HTMLTemplateElement && + newNode.shadowRootMode === 'open' && + oldNode.parentElement && + newNode.parentElement && + oldNode.parentElement.tagName === newNode.parentElement.tagName && + oldNode.parentElement?.shadowRoot != null + ) { + // Ignore template elements of components that are already initialized + return true; + } + + if (newNode.nodeType === Node.COMMENT_NODE && newNode.nodeValue === 'shopify:rendered_by_section_api') { + // Remove a comment node injected by the Section Rendering API in the Theme Editor + return true; + } + + return false; + }, + onBeforeUpdate(oldNode, newNode) { + if (oldNode instanceof Element && newNode instanceof Element) { + const attributes = ['product-grid-view', 'data-current-checked', 'data-previous-checked', 'cart-summary-sticky']; + + for (const attribute of attributes) { + const oldValue = oldNode.getAttribute(attribute); + const newValue = newNode.getAttribute(attribute); + + if (oldValue && oldValue !== newValue) { + newNode.setAttribute(attribute, oldValue); + } + } + + // Special case for elements that need to keep their style + const elements = ['floating-panel-component', 'fieldset.variant-option']; + const ids = ['account-popover']; + + for (const element of elements) { + if (oldNode.matches(element) && newNode.matches(element)) { + const oldStyle = oldNode.getAttribute('style'); + if (oldStyle) newNode.setAttribute('style', oldStyle); + } + } + for (const id of ids) { + if (oldNode.id === id && newNode.id === id) { + const oldStyle = oldNode.getAttribute('style'); + if (oldStyle) newNode.setAttribute('style', oldStyle); + } + } + + // Preserve temporary view transition name + if (oldNode instanceof HTMLElement && newNode instanceof HTMLElement && oldNode.style.viewTransitionName) { + newNode.style.viewTransitionName = oldNode.style.viewTransitionName; + } + } + }, + onAfterUpdate(node) { + if (node instanceof Component) { + queueMicrotask(() => node.updatedCallback()); + } + }, +}; + +/** + * Morphs one DOM tree into another by comparing nodes and applying minimal changes + * @param {Node} oldTree - The existing DOM tree + * @param {Node | string} newTree - The new DOM tree to morph to + * @param {Options} [options] - Configuration options + * @returns {Node} The morphed DOM tree + */ +export function morph(oldTree, newTree, options = MORPH_OPTIONS) { + if (!oldTree || !newTree) { + throw new Error('Both oldTree and newTree must be provided'); + } + + if (typeof newTree === 'string') { + const parsedNewTree = new DOMParser().parseFromString(newTree, 'text/html').body.firstChild; + if (!parsedNewTree) { + throw new Error('newTree string is not valid HTML'); + } + newTree = parsedNewTree; + } + + if (options.hydrationMode && oldTree instanceof Element && newTree instanceof Element) { + morphHydrationByKey(oldTree, newTree, options); + return oldTree; + } + + if (options.childrenOnly) { + updateChildren(newTree, oldTree, options); + return oldTree; + } + + if (newTree.nodeType === 11) { + throw new Error('newTree should have one root node (not a DocumentFragment)'); + } + + return walk(newTree, oldTree, options); +} + +/** + * Collect targets under a root element that have a non-empty key for the attribute. + * Includes the root itself if it matches. + * + * @param {Element} root + * @returns {Element[]} + */ +function collectHydrationTargets(root) { + const targets = []; + if (root.hasAttribute(HYDRATION_KEY_ATTRIBUTE)) targets.push(root); + targets.push(...root.querySelectorAll(`[${HYDRATION_KEY_ATTRIBUTE}]`)); + return targets; +} + +/** + * Morph only keyed targets from `newRoot` into `oldRoot` (a.k.a. "keyed lazy hydration"). + * + * Philosophy: + * - This updates the *contents* of pre-existing targets. We intentionally do NOT insert new targets (or remove missing ones). + * - By requiring targets to already exist in `oldRoot`, we preserve runtime state that may already + * be attached to the existing DOM (custom elements, listeners, focus, transient UI state) and preserve the layout and UI behavior. + * - Intended use-case: avoid expensive server-side rendering operations in the initial render, and hydrate targeted sections after page load. e.g. Querying all product and collection drops in off-screen menus. + * + * Contract: + * - An element is eligible only if it has `data-hydration-key=""`. + * - Matching uses ONLY that key value (no fallbacks) to avoid accidental cross-updates. + * - Once a target is matched, we run a normal morph *within that target* (attributes + children). + * + * @param {Element} oldRoot + * @param {Element} newRoot + * @param {Options} options + */ +function morphHydrationByKey(oldRoot, newRoot, options) { + const oldTargets = collectHydrationTargets(oldRoot); + const newTargets = collectHydrationTargets(newRoot); + + /** @type {Map} */ + const oldTargetsByKey = new Map(); + + for (const oldTarget of oldTargets) { + const key = oldTarget.getAttribute(HYDRATION_KEY_ATTRIBUTE); + if (key == null || key === '') continue; + + const existing = oldTargetsByKey.get(key) ?? []; + existing.push(oldTarget); + oldTargetsByKey.set(key, existing); + } + + for (const newTarget of newTargets) { + const key = newTarget.getAttribute(HYDRATION_KEY_ATTRIBUTE); + if (key == null || key === '') continue; + + const matches = oldTargetsByKey.get(key); + const oldTarget = matches?.shift(); + if (!oldTarget) continue; + + // For keyed targets we want attribute updates as well, regardless of the caller's childrenOnly default. + morph(oldTarget, newTarget, { + ...options, + hydrationMode: false, + childrenOnly: false, + }); + } +} + +/** + * Walk and morph a dom tree + * @param {Node} newNode - The new node to morph to + * @param {Node} oldNode - The old node to morph from + * @param {Options} options - The options object + * @returns {Node} The new node or the morphed old node + */ +function walk(newNode, oldNode, options) { + // Skip morphing if there is no old or new node + if (!oldNode) return newNode; + if (!newNode) return oldNode; + + // Skip morphing if nodes are identical + if (newNode.isSameNode?.(oldNode)) return oldNode; + + // Check node type and tag name first + if (newNode.nodeType !== oldNode.nodeType) return newNode; + if (newNode instanceof Element && oldNode instanceof Element) { + // Skip morphing if the node is shopify-accelerated-checkout-cart https://shopify.dev/docs/storefronts/themes/pricing-payments/accelerated-checkout#implement-accelerated-checkout-buttons-on-cart + if (oldNode.tagName === 'SHOPIFY-ACCELERATED-CHECKOUT-CART') return oldNode; + + if (newNode.tagName !== oldNode.tagName) return newNode; + + // Only check keys for elements, and only if both nodes have keys + const newKey = getNodeKey(newNode, options); + const oldKey = getNodeKey(oldNode, options); + if (newKey && oldKey && newKey !== oldKey) return newNode; + } + + // We can morph, update the node and its children + if ( + oldNode instanceof Element && + oldNode.hasAttribute('data-skip-node-update') && + newNode instanceof Element && + newNode.hasAttribute('data-skip-node-update') + ) { + // This is a special case where we don't want to morph the node, but we want to morph the children + updateChildren(newNode, oldNode, options); + } else { + updateNode(newNode, oldNode, options); + updateChildren(newNode, oldNode, options); + } + + options.onAfterUpdate?.(newNode); + + return oldNode; +} + +/** + * Core morphing function that updates attributes and special elements + * @param {Node} newNode - Source node with desired state + * @param {Node} oldNode - Target node to update + * @param {Options} options - The options object + */ +function updateNode(newNode, oldNode, options) { + options.onBeforeUpdate?.(oldNode, newNode); + + if ( + (newNode instanceof HTMLDetailsElement && oldNode instanceof HTMLDetailsElement) || + (newNode instanceof HTMLDialogElement && oldNode instanceof HTMLDialogElement) + ) { + if (!newNode.hasAttribute('declarative-open')) { + newNode.open = oldNode.open; + } + } + + if (oldNode instanceof HTMLElement && newNode instanceof HTMLElement) { + for (const attr of ['slot', 'sizes']) { + const oldValue = oldNode.getAttribute(attr); + const newValue = newNode.getAttribute(attr); + + if (oldValue !== newValue) { + oldValue == null ? newNode.removeAttribute(attr) : newNode.setAttribute(attr, oldValue); + } + } + } + + if (newNode instanceof Element && oldNode instanceof Element) { + if (!oldNode.isEqualNode(newNode)) { + copyAttributes(newNode, oldNode); + } + } else if (newNode instanceof Text || newNode instanceof Comment) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + } + + // Handle special elements + if (newNode instanceof HTMLInputElement && oldNode instanceof HTMLInputElement) { + updateInput(newNode, oldNode); + } else if (newNode instanceof HTMLOptionElement && oldNode instanceof HTMLOptionElement) { + updateAttribute(newNode, oldNode, 'selected'); + } else if (newNode instanceof HTMLTextAreaElement && oldNode instanceof HTMLTextAreaElement) { + updateTextarea(newNode, oldNode); + } +} + +/** + * Gets a node's key using the getNodeKey option if provided + * @param {Node | undefined} node - The node to get the key from + * @param {Options} [options] - The options object that may contain getNodeKey + * @returns {string|number|undefined} The node's key if one exists + */ +function getNodeKey(node, options) { + return options?.getNodeKey?.(node) ?? (node instanceof Element ? node.id : undefined); +} + +/** + * Updates a boolean attribute and its corresponding property on an element + * @param {any} newNode - The new element + * @param {any} oldNode - The existing element to update + * @param {string} name - The name of the attribute/property to update + */ +function updateAttribute(newNode, oldNode, name) { + if (newNode[name] !== oldNode[name]) { + oldNode[name] = newNode[name]; + if (newNode[name] != null) { + oldNode.setAttribute(name, ''); + } else { + oldNode.removeAttribute(name); + } + } +} + +/** + * Copies attributes from a new node to an old node, handling namespaced attributes + * @param {Element} newNode - The new node to copy attributes from + * @param {Element} oldNode - The existing node to update attributes on + */ +function copyAttributes(newNode, oldNode) { + const oldAttrs = oldNode.attributes; + const newAttrs = newNode.attributes; + + // Update or add new attributes + for (const attr of Array.from(newAttrs)) { + const { name: attrName, namespaceURI: attrNamespaceURI, value: attrValue } = attr; + const localName = attr.localName || attrName; + + if (attrName === 'src' || attrName === 'href' || attrName === 'srcset' || attrName === 'poster') { + // Skip updating resource attributes when the value hasn't changed + // to prevent unnecessary network requests + if (oldNode.getAttribute(attrName) === attrValue) continue; + } + + if (attrNamespaceURI) { + const fromValue = oldNode.getAttributeNS(attrNamespaceURI, localName); + if (fromValue !== attrValue) { + oldNode.setAttributeNS(attrNamespaceURI, localName, attrValue); + } + } else { + if (!oldNode.hasAttribute(attrName)) { + oldNode.setAttribute(attrName, attrValue); + } else { + const fromValue = oldNode.getAttribute(attrName); + if (fromValue !== attrValue) { + if (attrValue === 'null' || attrValue === 'undefined') { + oldNode.removeAttribute(attrName); + } else { + oldNode.setAttribute(attrName, attrValue); + } + } + } + } + } + + // Remove old attributes not present in new node + for (const attr of Array.from(oldAttrs)) { + if (attr.specified === false) continue; + + const { name: attrName, namespaceURI: attrNamespaceURI } = attr; + const localName = attr.localName || attrName; + + if (attrNamespaceURI) { + if (!newNode.hasAttributeNS(attrNamespaceURI, localName)) { + oldNode.removeAttributeNS(attrNamespaceURI, localName); + } + } else if (!newNode.hasAttribute(attrName)) { + oldNode.removeAttribute(attrName); + } + } +} + +/** + * Updates special properties and attributes on input elements + * Handles checked, disabled, indeterminate states and value + * @param {HTMLInputElement} newNode - The new input element + * @param {HTMLInputElement} oldNode - The existing input element to update + */ +function updateInput(newNode, oldNode) { + const newValue = newNode.value; + + updateAttribute(newNode, oldNode, 'checked'); + updateAttribute(newNode, oldNode, 'disabled'); + + // Handle indeterminate state (cannot be set via HTML attribute) + if (newNode.indeterminate !== oldNode.indeterminate) { + oldNode.indeterminate = newNode.indeterminate; + } + + // Skip file inputs since they can't be changed programmatically + if (oldNode.type === 'file') return; + + if (newValue !== oldNode.value) { + oldNode.setAttribute('value', newValue); + oldNode.value = newValue; + } + + if (newValue === 'null') { + oldNode.value = ''; + oldNode.removeAttribute('value'); + } + + if (!newNode.hasAttributeNS(null, 'value')) { + oldNode.removeAttribute('value'); + } else if (oldNode.type === 'range') { + // Update range input UI + oldNode.value = newValue; + } +} + +/** + * Updates the value of a textarea element + * @param {HTMLTextAreaElement} newNode - The new textarea element + * @param {HTMLTextAreaElement} oldNode - The existing textarea element to update + */ +function updateTextarea(newNode, oldNode) { + const newValue = newNode.value; + if (newValue !== oldNode.value) { + oldNode.value = newValue; + } + + const firstChild = oldNode.firstChild; + if (firstChild?.nodeType === Node.TEXT_NODE) { + if (newValue === '' && firstChild.nodeValue === oldNode.placeholder) { + return; + } + firstChild.nodeValue = newValue; + } +} + +/** + * If app scripts store references to the DOM on initialization, they will be invalidated by the morph because browsers don't re-execute them. + * This function removes and recreates them to force re-execution. + * @param {Element} container - The container element to search for app block scripts + */ +function recreateAppBlockScripts(container) { + const scripts = container.querySelectorAll('.shopify-app-block script[src]'); + + for (const script of scripts) { + if (!(script instanceof HTMLScriptElement)) continue; + + const parent = script.parentElement; + if (!parent) continue; + + const newScript = document.createElement('script'); + for (const attr of Array.from(script.attributes)) { + newScript.setAttribute(attr.name, attr.value); + } + if (script.textContent) { + newScript.textContent = script.textContent; + } + + script.remove(); + parent.appendChild(newScript); + } +} + +/** + * Update the children of elements + * @param {Node} newNode - The new node to update children on + * @param {Node} oldNode - The existing node to update children on + * @param {Options} options - The options object + */ +function updateChildren(newNode, oldNode, options) { + if ( + oldNode instanceof Element && + oldNode.hasAttribute('data-skip-subtree-update') && + newNode instanceof Element && + newNode.hasAttribute('data-skip-subtree-update') + ) { + return; + } + + let oldChild, newChild, morphed, oldMatch; + let offset = 0; + + for (let i = 0; ; i++) { + oldChild = oldNode.childNodes[i]; + newChild = newNode.childNodes[i - offset]; + + // Both nodes are empty, do nothing + if (!oldChild && !newChild) { + break; + } + + // There is no new child, remove old + if (!newChild) { + oldChild && oldNode.removeChild(oldChild); + i--; + continue; + } + + // There is no old child, add new + if (!oldChild) { + oldNode.appendChild(newChild); + offset++; + continue; + } + + // Both nodes are the same, morph + if (same(newChild, oldChild, options)) { + morphed = walk(newChild, oldChild, options); + if (morphed !== oldChild) { + oldNode.replaceChild(morphed, oldChild); + offset++; + } + continue; + } + + if (options.reject?.(oldChild, newChild)) { + newNode.removeChild(newChild); + i--; + continue; + } + + // Try to find a matching node to reorder + oldMatch = null; + for (let j = i; j < oldNode.childNodes.length; j++) { + const potentialOldNode = oldNode.childNodes[j]; + + if (potentialOldNode && same(potentialOldNode, newChild, options)) { + oldMatch = potentialOldNode; + break; + } + } + + if (oldMatch) { + morphed = walk(newChild, oldMatch, options); + if (morphed !== oldMatch) offset++; + oldNode.insertBefore(morphed, oldChild); + } else if (!getNodeKey(newChild, options) && !getNodeKey(oldChild, options)) { + morphed = walk(newChild, oldChild, options); + if (morphed !== oldChild) { + oldNode.replaceChild(morphed, oldChild); + offset++; + } + } else { + oldNode.insertBefore(newChild, oldChild); + offset++; + } + } + + // Recreate app block scripts to bypass browser script deduplication + if (oldNode instanceof Element) { + recreateAppBlockScripts(oldNode); + } +} + +/** + * Check if two nodes are the same + * @param {Node} a - The first node + * @param {Node} b - The second node + * @param {Options} options - The options object + * @returns {boolean} True if the nodes are the same, false otherwise + */ +function same(a, b, options) { + // If node types don't match, they're not the same + if (a.nodeType !== b.nodeType) return false; + + // For elements, check tag name first + if (a.nodeType === Node.ELEMENT_NODE) { + if (a instanceof Element && b instanceof Element && a.tagName !== b.tagName) return false; + + // Only compare keys if both nodes have them + const aKey = getNodeKey(a, options); + const bKey = getNodeKey(b, options); + if (aKey && bKey && aKey !== bKey) return false; + } + + // For text/comment nodes, compare content + if (a.nodeType === Node.TEXT_NODE && b.nodeType === Node.TEXT_NODE) + // Trim whitespace to avoid false negatives + return a.nodeValue?.trim() === b.nodeValue?.trim(); + if (a.nodeType === Node.COMMENT_NODE && b.nodeType === Node.COMMENT_NODE) return a.nodeValue === b.nodeValue; + + // If we get here and nodes are elements with same tag (and compatible keys), they're the same + return true; +} diff --git a/assets/overflow-list.css b/assets/overflow-list.css new file mode 100644 index 000000000..e40cc991b --- /dev/null +++ b/assets/overflow-list.css @@ -0,0 +1,66 @@ +[part='list'] { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: var(--overflow-list-alignment); + column-gap: 1rem; + padding-block: var(--overflow-list-padding-block, 0); + padding-inline: var(--overflow-list-padding-inline, 0); + height: 100%; + + @media screen and (max-width: 749px) { + justify-content: var(--overflow-list-alignment-mobile); + } + + overflow-x: auto; + overflow-y: hidden; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +[part='list'], +[part='overflow-list'], +[part='placeholder'] { + margin: 0; + padding: 0; + list-style: none; +} + +/* Make sure the "more" slot can be measured */ +slot[name='more']:not([hidden]) { + display: flex; + height: 100%; +} + +slot[name='more'] .button { + cursor: pointer; + border: none; + background: none; + padding: 0; + margin: 0; + font-family: var(--font-paragraph-family); + font-size: var(--font-paragraph-size); + text-transform: var(--text-transform); + color: currentcolor; + text-align: start; +} + +[part='overflow'] { + display: none; +} + +[part='placeholder'] { + visibility: hidden; + width: 0; + height: 0; +} + +:host([disabled]) { + slot[name='more'] { + display: none; + } +} diff --git a/assets/overflow-list.js b/assets/overflow-list.js new file mode 100644 index 000000000..020e66d5f --- /dev/null +++ b/assets/overflow-list.js @@ -0,0 +1,386 @@ +import { ResizeNotifier } from '@theme/utilities'; +import { DeclarativeShadowElement } from '@theme/component'; + +/** + * Event class for overflow minimum items updates + * @extends {Event} + */ +export class OverflowMinimumEvent extends Event { + /** + * Creates a new OverflowMinimumEvent + * @param {boolean} minimumReached - Whether the minimum number of visible items has been reached + */ + constructor(minimumReached) { + super('overflowMinimum', { bubbles: true }); + this.detail = { + minimumReached, + }; + } +} + +/** + * A custom element that wraps a list of items and moves them to an overflow slot when they don't fit. + * This component is used in the header section and other areas. + * @attr {string | null} minimum-items When set, the element enters a 'minimum-reached' state when visible items are at or below this number. + * @example + * + * + * + */ +export class OverflowList extends DeclarativeShadowElement { + static get observedAttributes() { + return ['disabled', 'minimum-items']; + } + + /** + * @param {string} name + * @param {string} oldValue + * @param {string} newValue + */ + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'disabled') { + if (newValue === 'true') { + this.#reset(); + } else { + this.#reflowItems(); + } + } + } + + async connectedCallback() { + super.connectedCallback(); + + // Styles for dynamically injected elements are async. + // We need to wait for them to be loaded before initializing the element to properly calculate the overflow. + await this.#waitForStyles(); + + this.#initialize(); + } + + #waitForStyles() { + /** @type {HTMLLinkElement | null | undefined} */ + const styles = this.shadowRoot?.querySelector('link[rel="stylesheet"]'); + + // No styles or styles are already loaded. + if (!styles || styles.sheet) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + styles.addEventListener('load', resolve); + }); + } + + /** + * Initialize the element + */ + #initialize() { + const { shadowRoot } = this; + + if (!shadowRoot) throw new Error('Missing shadow root'); + + const defaultSlot = shadowRoot.querySelector('slot:not([name])'); + const overflowSlot = shadowRoot.querySelector('slot[name="overflow"]'); + const moreSlot = shadowRoot.querySelector('slot[name="more"]'); + const overflow = shadowRoot.querySelector('[part="overflow"]'); + const list = shadowRoot.querySelector('[part="list"]'); + const placeholder = shadowRoot.querySelector('[part="placeholder"]'); + + if ( + !(defaultSlot instanceof HTMLSlotElement) || + !(overflowSlot instanceof HTMLSlotElement) || + !(moreSlot instanceof HTMLSlotElement) || + !(overflow instanceof HTMLElement) || + !(list instanceof HTMLUListElement) || + !(placeholder instanceof HTMLLIElement) + ) { + throw new Error('Invalid element types in '); + } + + this.#refs = { + defaultSlot, + overflowSlot, + moreSlot, + overflow, + list, + placeholder, + }; + + // Add event listener for reflow requests + this.addEventListener( + 'reflow', + /** @param {CustomEvent<{lastVisibleElement?: HTMLElement}>} event */ (event) => { + this.#reflowItems(0, event.detail.lastVisibleElement); + } + ); + + // When is dynamically injected, the browser doesn't remove its