diff --git a/assets/animations.js b/assets/animations.js index 2622f602e00..bc9a947dde5 100644 --- a/assets/animations.js +++ b/assets/animations.js @@ -1,9 +1,15 @@ +// Class names for scroll animations const SCROLL_ANIMATION_TRIGGER_CLASSNAME = 'scroll-trigger'; const SCROLL_ANIMATION_OFFSCREEN_CLASSNAME = 'scroll-trigger--offscreen'; const SCROLL_ZOOM_IN_TRIGGER_CLASSNAME = 'animate--zoom-in'; const SCROLL_ANIMATION_CANCEL_CLASSNAME = 'scroll-trigger--cancel'; -// Scroll in animation logic +/** + * Callback for the IntersectionObserver. + * Handles adding/removing animation classes based on the element's visibility in the viewport. + * @param {IntersectionObserverEntry[]} elements - The elements being observed. + * @param {IntersectionObserver} observer - The IntersectionObserver instance. + */ function onIntersection(elements, observer) { elements.forEach((element, index) => { if (element.isIntersecting) { @@ -21,6 +27,11 @@ function onIntersection(elements, observer) { }); } +/** + * Initialize scroll animation triggers. + * @param {HTMLElement} [rootEl=document] - The root element to search for scroll animation triggers. + * @param {boolean} [isDesignModeEvent=false] - Flag to indicate if the function is being called from a Shopify design mode event. + */ function initializeScrollAnimationTrigger(rootEl = document, isDesignModeEvent = false) { const animationTriggerElements = Array.from(rootEl.getElementsByClassName(SCROLL_ANIMATION_TRIGGER_CLASSNAME)); if (animationTriggerElements.length === 0) return; @@ -38,7 +49,9 @@ function initializeScrollAnimationTrigger(rootEl = document, isDesignModeEvent = animationTriggerElements.forEach((element) => observer.observe(element)); } -// Zoom in animation logic +/** + * Initialize scroll zoom animation triggers. + */ function initializeScrollZoomAnimationTrigger() { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; @@ -71,6 +84,11 @@ function initializeScrollZoomAnimationTrigger() { }); } +/** + * Calculates percentage of element visible in viewport. + * @param {HTMLElement} element - The element to calculate the visibility percentage of. + * @returns {number} The visibility percentage of the element. + */ function percentageSeen(element) { const viewportHeight = window.innerHeight; const scrollY = window.scrollY; @@ -91,11 +109,13 @@ function percentageSeen(element) { return Math.round(percentage); } +// Initialize animations on DOM content loaded window.addEventListener('DOMContentLoaded', () => { initializeScrollAnimationTrigger(); initializeScrollZoomAnimationTrigger(); }); +// Shopify design mode event listeners if (Shopify.designMode) { document.addEventListener('shopify:section:load', (event) => initializeScrollAnimationTrigger(event.target, true)); document.addEventListener('shopify:section:reorder', () => initializeScrollAnimationTrigger(document, true)); diff --git a/assets/cart-drawer.js b/assets/cart-drawer.js index ad37f3cb871..1093179d1fe 100644 --- a/assets/cart-drawer.js +++ b/assets/cart-drawer.js @@ -1,3 +1,7 @@ +/** + * Cart Drawer custom element class. + * @extends HTMLElement + */ class CartDrawer extends HTMLElement { constructor() { super(); @@ -7,6 +11,9 @@ class CartDrawer extends HTMLElement { this.setHeaderCartIconAccessibility(); } + /** + * Sets accessibility attributes for header cart icon. + */ setHeaderCartIconAccessibility() { const cartLink = document.querySelector('#cart-icon-bubble'); if (!cartLink) return; @@ -25,6 +32,10 @@ class CartDrawer extends HTMLElement { }); } + /** + * Opens the cart drawer. + * @param {HTMLElement} [triggeredBy] - The element that triggered the open action. + */ open(triggeredBy) { if (triggeredBy) this.setActiveElement(triggeredBy); const cartDrawerNote = this.querySelector('[id^="Details-"] summary'); @@ -49,12 +60,18 @@ class CartDrawer extends HTMLElement { document.body.classList.add('overflow-hidden'); } + /** + * Closes the cart drawer. + */ close() { this.classList.remove('active'); removeTrapFocus(this.activeElement); document.body.classList.remove('overflow-hidden'); } + /** + * Sets accessibility attributes for the cart drawer note. + */ setSummaryAccessibility(cartDrawerNote) { cartDrawerNote.setAttribute('role', 'button'); cartDrawerNote.setAttribute('aria-expanded', 'false'); @@ -70,6 +87,10 @@ class CartDrawer extends HTMLElement { cartDrawerNote.parentElement.addEventListener('keyup', onKeyUpEscape); } + /** + * Renders the cart drawer contents. + * @param {Object} parsedState - The parsed state of the cart. + */ renderContents(parsedState) { this.querySelector('.drawer__inner').classList.contains('is-empty') && this.querySelector('.drawer__inner').classList.remove('is-empty'); @@ -89,10 +110,20 @@ class CartDrawer extends HTMLElement { }); } + /** + * Gets the inner HTML of a cart section. + * @param {string} html - The HTML string to parse. + * @param {string} [selector='.shopify-section'] - The selector to query for. + * @returns {string} The inner HTML of the matched section. + */ getSectionInnerHTML(html, selector = '.shopify-section') { return new DOMParser().parseFromString(html, 'text/html').querySelector(selector).innerHTML; } + /** + * Array of section selectors for sections api to render. + * @returns {Array} The array of sections to render. + */ getSectionsToRender() { return [ { @@ -105,10 +136,20 @@ class CartDrawer extends HTMLElement { ]; } + /** + * Gets the DOM of a cart drawer section. + * @param {string} html - The HTML string to parse. + * @param {string} [selector='.shopify-section'] - The selector to query for. + * @returns {HTMLElement} The DOM of the matched section. + */ getSectionDOM(html, selector = '.shopify-section') { return new DOMParser().parseFromString(html, 'text/html').querySelector(selector); } + /** + * Sets the active element. + * @param {HTMLElement} element - The element to set as active. + */ setActiveElement(element) { this.activeElement = element; } @@ -116,7 +157,15 @@ class CartDrawer extends HTMLElement { customElements.define('cart-drawer', CartDrawer); +/** + * Cart Drawer Items custom element class. + * @extends CartItems + */ class CartDrawerItems extends CartItems { + /** + * Array of section selectors for sections api to render. + * @returns {Array} The array of sections to render. + */ getSectionsToRender() { return [ { diff --git a/assets/cart-notification.js b/assets/cart-notification.js index 7e8a06cdf2e..1390a66b6cb 100644 --- a/assets/cart-notification.js +++ b/assets/cart-notification.js @@ -1,8 +1,14 @@ +/** + * Cart Notification custom element class. + * @extends HTMLElement + */ class CartNotification extends HTMLElement { constructor() { super(); + /** @type {HTMLElement} */ this.notification = document.getElementById('cart-notification'); + /** @type {HTMLElement} */ this.header = document.querySelector('sticky-header'); this.onBodyClick = this.handleBodyClick.bind(this); @@ -12,6 +18,9 @@ class CartNotification extends HTMLElement { ); } + /** + * Open the cart notification. + */ open() { this.notification.classList.add('animate', 'active'); @@ -27,6 +36,9 @@ class CartNotification extends HTMLElement { document.body.addEventListener('click', this.onBodyClick); } + /** + * Close the cart notification. + */ close() { this.notification.classList.remove('active'); document.body.removeEventListener('click', this.onBodyClick); @@ -34,6 +46,10 @@ class CartNotification extends HTMLElement { removeTrapFocus(this.activeElement); } + /** + * Replace DOM with parsed state from Sections API. + * @param {Object} parsedState - The parsed state object from Sections API. + */ renderContents(parsedState) { this.cartItemKey = parsedState.key; this.getSectionsToRender().forEach((section) => { @@ -47,6 +63,10 @@ class CartNotification extends HTMLElement { this.open(); } + /** + * Array of sections to fetch from the Sections API. + * @returns {Array} Array of sections to fetch from the Sections API. + */ getSectionsToRender() { return [ { @@ -62,10 +82,20 @@ class CartNotification extends HTMLElement { ]; } + /** + * Gets the inner HTML of a cart section. + * @param {string} html - The HTML string to parse. + * @param {string} [selector='.shopify-section'] - The selector to query for. + * @returns {string} The inner HTML of the matched section. + */ getSectionInnerHTML(html, selector = '.shopify-section') { return new DOMParser().parseFromString(html, 'text/html').querySelector(selector).innerHTML; } + /** + * Handle body click events to close the cart notification if clicked outside. + * @param {MouseEvent} evt - The click event. + */ handleBodyClick(evt) { const target = evt.target; if (target !== this.notification && !target.closest('cart-notification')) { @@ -75,6 +105,10 @@ class CartNotification extends HTMLElement { } } + /** + * Sets the active element. + * @param {HTMLElement} element - The element to set as active. + */ setActiveElement(element) { this.activeElement = element; } diff --git a/assets/cart.js b/assets/cart.js index eef5f1f66d5..c353126a6c3 100644 --- a/assets/cart.js +++ b/assets/cart.js @@ -1,3 +1,7 @@ +/** + * Button to remove a cart line item. + * @extends HTMLElement + */ class CartRemoveButton extends HTMLElement { constructor() { super(); @@ -12,9 +16,14 @@ class CartRemoveButton extends HTMLElement { customElements.define('cart-remove-button', CartRemoveButton); +/** + * Cart Line Item custom element class. + * @extends HTMLElement + */ class CartItems extends HTMLElement { constructor() { super(); + /** @type {HTMLElement} */ this.lineItemStatusElement = document.getElementById('shopping-cart-line-item-status') || document.getElementById('CartDrawer-LineItemStatus'); @@ -25,6 +34,7 @@ class CartItems extends HTMLElement { this.addEventListener('change', debouncedOnChange.bind(this)); } + /** @type {Function|undefined} */ cartUpdateUnsubscriber = undefined; connectedCallback() { @@ -42,12 +52,22 @@ class CartItems extends HTMLElement { } } + /** + * Reset the quantity input to its original value. + * @param {string} id - The ID of the quantity input. + */ resetQuantityInput(id) { const input = this.querySelector(`#Quantity-${id}`); input.value = input.getAttribute('value'); this.isEnterPressed = false; } + /** + * Set the validity of the quantity input. + * @param {Event} event - The event object. + * @param {string} index - The index of the quantity input. + * @param {string} message - The validation error message. + */ setValidity(event, index, message) { event.target.setCustomValidity(message); event.target.reportValidity(); @@ -55,6 +75,10 @@ class CartItems extends HTMLElement { event.target.select(); } + /** + * Validate the quantity input. + * @param {Event} event - The event object. + */ validateQuantity(event) { const inputValue = parseInt(event.target.value); const index = event.target.dataset.index; @@ -82,10 +106,17 @@ class CartItems extends HTMLElement { } } + /** + * Handle the change event on the quantity input. + * @param {Event} event - The event object. + */ onChange(event) { this.validateQuantity(event); } + /** + * Update cart DOM after a cart update. + */ onCartUpdate() { if (this.tagName === 'CART-DRAWER-ITEMS') { fetch(`${routes.cart_url}?section_id=cart-drawer`) @@ -118,6 +149,10 @@ class CartItems extends HTMLElement { } } + /** + * Array of section selectors for sections api to render. + * @returns {Array} The array of sections to render. + */ getSectionsToRender() { return [ { @@ -143,6 +178,13 @@ class CartItems extends HTMLElement { ]; } + /** + * Update the quantity of a cart line item. + * @param {string} line - The index of the line item to update. + * @param {number} quantity - The new quantity of the line item. + * @param {string} name - The name attribute of the line item (for focus trapping). + * @param {string} variantId - The variant ID of the line item. + */ updateQuantity(line, quantity, name, variantId) { this.enableLoading(line); @@ -219,6 +261,11 @@ class CartItems extends HTMLElement { }); } + /** + * Update live regions with a message. + * @param {string} line - The line item index. + * @param {string} message - The message to display. + */ updateLiveRegions(line, message) { const lineItemError = document.getElementById(`Line-item-error-${line}`) || document.getElementById(`CartDrawer-LineItemError-${line}`); @@ -235,10 +282,20 @@ class CartItems extends HTMLElement { }, 1000); } + /** + * Gets the inner HTML of a cart section. + * @param {string} html - The HTML string to parse. + * @param {string} selector - The selector to query for. + * @returns {string} The inner HTML of the matched section. + */ getSectionInnerHTML(html, selector) { return new DOMParser().parseFromString(html, 'text/html').querySelector(selector).innerHTML; } + /** + * Enable loading state for a cart line item. + * @param {string} line - The index of the line item. + */ enableLoading(line) { const mainCartItems = document.getElementById('main-cart-items') || document.getElementById('CartDrawer-CartItems'); mainCartItems.classList.add('cart__items--disabled'); @@ -252,6 +309,10 @@ class CartItems extends HTMLElement { this.lineItemStatusElement.setAttribute('aria-hidden', false); } + /** + * Disable loading state for a cart line item. + * @param {string} line - The index of the line item. + */ disableLoading(line) { const mainCartItems = document.getElementById('main-cart-items') || document.getElementById('CartDrawer-CartItems'); mainCartItems.classList.remove('cart__items--disabled'); @@ -269,6 +330,10 @@ customElements.define('cart-items', CartItems); if (!customElements.get('cart-note')) { customElements.define( 'cart-note', + /** + * Cart Note custom element class. + * @extends HTMLElement + */ class CartNote extends HTMLElement { constructor() { super(); diff --git a/assets/constants.js b/assets/constants.js index 8c405e63e66..f292ef49f33 100644 --- a/assets/constants.js +++ b/assets/constants.js @@ -1,5 +1,17 @@ +/** Default debounce time. */ const ON_CHANGE_DEBOUNCE_TIMER = 300; +/** + * Publish/subscribe events enum. + * @typedef {Object} PUB_SUB_EVENTS + * @property {string} cartUpdate - Cart update event. + * @property {string} quantityUpdate - Quantity update event. + * @property {string} optionValueSelectionChange - Option value selection change event. + * @property {string} variantChange - Variant change event. + * @property {string} cartError - Cart error event. + */ + +/** @type {PUB_SUB_EVENTS} */ const PUB_SUB_EVENTS = { cartUpdate: 'cart-update', quantityUpdate: 'quantity-update', diff --git a/assets/customer.js b/assets/customer.js index c608ccedd2e..a09616cd933 100644 --- a/assets/customer.js +++ b/assets/customer.js @@ -1,3 +1,26 @@ +/** + * @typedef CustomerAddressSelectors + * @property {string} customerAddresses - Customer addresses container selector. + * @property {string} addressCountrySelect - Address country select selector. + * @property {string} addressContainer - Address container selector. + * @property {string} toggleAddressButton - Toggle address button selector. + * @property {string} cancelAddressButton - Cancel address button selector. + * @property {string} deleteAddressButton - Delete address button selector. + * + * @typedef CustomerAddressAttributes + * @property {string} expanded - Expanded attribute. + * @property {string} confirmMessage - Confirm message attribute + * + * @typedef CustomerAddressElements + * @property {HTMLElement} container - Customer addresses container element. + * @property {HTMLElement} addressContainer - Address container element. + * @property {NodeList} toggleButtons - Toggle address button elements. + * @property {NodeList} cancelButtons - Cancel address button elements. + * @property {NodeList} deleteButtons - Delete address button elements. + * @property {NodeList} countrySelects - Address country select elements. + */ + +/** @type {CustomerAddressSelectors} */ const selectors = { customerAddresses: '[data-customer-addresses]', addressCountrySelect: '[data-address-country-select]', @@ -7,19 +30,27 @@ const selectors = { deleteAddressButton: 'button[data-confirm-message]', }; +/** @type {CustomerAddressAttributes} */ const attributes = { expanded: 'aria-expanded', confirmMessage: 'data-confirm-message', }; +/** Customer addressed management class. */ class CustomerAddresses { constructor() { + /** @type {CustomerAddressElements} */ this.elements = this._getElements(); if (Object.keys(this.elements).length === 0) return; this._setupCountries(); this._setupEventListeners(); } + /** + * Gather actionable address elements. + * @returns {CustomerAddressElements} elements required for address management. + * @private + */ _getElements() { const container = document.querySelector(selectors.customerAddresses); return container @@ -34,6 +65,10 @@ class CustomerAddresses { : {}; } + /** + * Set up country and province selectors. + * @private + */ _setupCountries() { if (Shopify && Shopify.CountryProvinceSelector) { // eslint-disable-next-line no-new @@ -50,6 +85,10 @@ class CustomerAddresses { } } + /** + * Set up event listeners for actionable address elements. + * @private + */ _setupEventListeners() { this.elements.toggleButtons.forEach((element) => { element.addEventListener('click', this._handleAddEditButtonClick); @@ -62,18 +101,38 @@ class CustomerAddresses { }); } + /** + * Toggle address container expanded state. + * @param {HTMLElement} target - The target element to toggle expanded state. + * @private + */ _toggleExpanded(target) { target.setAttribute(attributes.expanded, (target.getAttribute(attributes.expanded) === 'false').toString()); } + /** + * Handle add/edit button click event. Expand the address container. + * @param {Event} event - The click event object. + * @private + */ _handleAddEditButtonClick = ({ currentTarget }) => { this._toggleExpanded(currentTarget); }; + /** + * Handle cancel button click event. Close the address container. + * @param {Event} event - The click event object. + * @private + */ _handleCancelButtonClick = ({ currentTarget }) => { this._toggleExpanded(currentTarget.closest(selectors.addressContainer).querySelector(`[${attributes.expanded}]`)); }; + /** + * Handle delete button click event. Confirm before submitting the deletion form. + * @param {Event} event - The click event object. + * @private + */ _handleDeleteButtonClick = ({ currentTarget }) => { // eslint-disable-next-line no-alert if (confirm(currentTarget.getAttribute(attributes.confirmMessage))) { diff --git a/assets/details-disclosure.js b/assets/details-disclosure.js index b4680b7db8f..a0a02b7d963 100644 --- a/assets/details-disclosure.js +++ b/assets/details-disclosure.js @@ -1,19 +1,31 @@ +/** + * Details Disclosure custom element class. + * @extends HTMLElement + */ class DetailsDisclosure extends HTMLElement { constructor() { super(); + /** @type {HTMLElement} */ this.mainDetailsToggle = this.querySelector('details'); + /** @type {HTMLElement} */ this.content = this.mainDetailsToggle.querySelector('summary').nextElementSibling; this.mainDetailsToggle.addEventListener('focusout', this.onFocusOut.bind(this)); this.mainDetailsToggle.addEventListener('toggle', this.onToggle.bind(this)); } + /** + * Handle focus out event. Closes the details disclosure if focus is outside of it. + */ onFocusOut() { setTimeout(() => { if (!this.contains(document.activeElement)) this.close(); }); } + /** + * Handle toggle event. Play or cancel animations when the details disclosure is toggled. + */ onToggle() { if (!this.animations) this.animations = this.content.getAnimations(); @@ -24,6 +36,9 @@ class DetailsDisclosure extends HTMLElement { } } + /** + * Close the details disclosure. + */ close() { this.mainDetailsToggle.removeAttribute('open'); this.mainDetailsToggle.querySelector('summary').setAttribute('aria-expanded', false); @@ -32,12 +47,21 @@ class DetailsDisclosure extends HTMLElement { customElements.define('details-disclosure', DetailsDisclosure); +/** + * Header Menu custom element class. + * @extends DetailsDisclosure + */ class HeaderMenu extends DetailsDisclosure { constructor() { super(); + /** @type {HTMLElement|undefined} */ this.header = document.querySelector('.header-wrapper'); } + + /** + * Handle toggle event, Toggles the visibility of the header menu details disclosure. + */ onToggle() { if (!this.header) return; this.header.preventHide = this.mainDetailsToggle.open; diff --git a/assets/details-modal.js b/assets/details-modal.js index 4d7002db6da..9311306d075 100644 --- a/assets/details-modal.js +++ b/assets/details-modal.js @@ -1,7 +1,13 @@ +/** + * Details Modal custom element class. + * @extends HTMLElement + */ class DetailsModal extends HTMLElement { constructor() { super(); + /** @type {HTMLElement} */ this.detailsContainer = this.querySelector('details'); + /** @type {HTMLElement} */ this.summaryToggle = this.querySelector('summary'); this.detailsContainer.addEventListener('keyup', (event) => event.code.toUpperCase() === 'ESCAPE' && this.close()); @@ -11,19 +17,37 @@ class DetailsModal extends HTMLElement { this.summaryToggle.setAttribute('role', 'button'); } + /** + * Return open state of modal. + * @returns {boolean} - True if modal is open, false otherwise. + */ isOpen() { return this.detailsContainer.hasAttribute('open'); } + /** + * Handle summary element click event. + * Toggles the open state of the modal. + * @param {Event} event - The click event object. + */ onSummaryClick(event) { event.preventDefault(); event.target.closest('details').hasAttribute('open') ? this.close() : this.open(event); } + /** + * Handle body click event. + * Closes the modal if click is outside of it. + * @param {Event} event - The click event object. + */ onBodyClick(event) { if (!this.contains(event.target) || event.target.classList.contains('modal-overlay')) this.close(false); } + /** + * Open the modal and trap focus. + * @param {Event} event - The click event object. + */ open(event) { this.onBodyClickEvent = this.onBodyClickEvent || this.onBodyClick.bind(this); event.target.closest('details').setAttribute('open', true); @@ -36,6 +60,10 @@ class DetailsModal extends HTMLElement { ); } + /** + * Close the modal and release focus. + * @param {boolean} [focusToggle=true] - Whether to focus the summary toggle element after closing. + */ close(focusToggle = true) { removeTrapFocus(focusToggle ? this.summaryToggle : null); this.detailsContainer.removeAttribute('open'); diff --git a/assets/facets.js b/assets/facets.js index 9305e5f3c69..c68840f8e4c 100644 --- a/assets/facets.js +++ b/assets/facets.js @@ -1,8 +1,21 @@ +/** + * Facet Filters Form custom element class. + * @extends HTMLElement + */ class FacetFiltersForm extends HTMLElement { constructor() { super(); + /** + * Handle click event on active filter. + * Remove active filter and trigger page update. + * @param {MouseEvent} event - Event object that triggered the click. + */ this.onActiveFilterClick = this.onActiveFilterClick.bind(this); + /** + * Debounce submit handler to prevent excessive form submissions. + * @param {SubmitEvent} event - Event object. + */ this.debouncedOnSubmit = debounce((event) => { this.onSubmitHandler(event); }, 800); @@ -14,7 +27,15 @@ class FacetFiltersForm extends HTMLElement { if (facetWrapper) facetWrapper.addEventListener('keyup', onKeyUpEscape); } + /** + * Set event listeners for facet filters form. + */ static setListeners() { + /** + * Handle window history change event. + * Update page with search parameters from history state. + * @param {PopStateEvent} event - Popstate event object. + */ const onHistoryChange = (event) => { const searchParams = event.state ? event.state.searchParams : FacetFiltersForm.searchParamsInitial; if (searchParams === FacetFiltersForm.searchParamsPrev) return; @@ -23,12 +44,22 @@ class FacetFiltersForm extends HTMLElement { window.addEventListener('popstate', onHistoryChange); } + /** + * Toggle active facet states. + * @param {boolean} [disable=true] - Whether to disable all active facets. + */ static toggleActiveFacets(disable = true) { document.querySelectorAll('.js-facet-remove').forEach((element) => { element.classList.toggle('disabled', disable); }); } + /** + * Update DOM of facets and loading states. + * @param {string} searchParams - The search parameters to update the facets with. + * @param {Event | null} event - The event object that triggered the update. + * @param {boolean} [updateURLHash=true] - Whether to update the URL hash. + */ static renderPage(searchParams, event, updateURLHash = true) { FacetFiltersForm.searchParamsPrev = searchParams; const sections = FacetFiltersForm.getSections(); @@ -58,6 +89,11 @@ class FacetFiltersForm extends HTMLElement { if (updateURLHash) FacetFiltersForm.updateURLHash(searchParams); } + /** + * Fetch and update DOM with section from Sections API. + * @param {string} url - Sections API URL to fetch the section from. + * @param {SubmitEvent | null} event - Event object that triggered the fetch. + */ static renderSectionFromFetch(url, event) { fetch(url) .then((response) => response.text()) @@ -71,6 +107,11 @@ class FacetFiltersForm extends HTMLElement { }); } + /** + * Update DOM with section from cache. + * @param {Function} filterDataUrl - Filter function to filter cached data. + * @param {Event} event - Event object that triggered the update. + */ static renderSectionFromCache(filterDataUrl, event) { const html = FacetFiltersForm.filterData.find(filterDataUrl).html; FacetFiltersForm.renderFilters(html, event); @@ -79,6 +120,10 @@ class FacetFiltersForm extends HTMLElement { if (typeof initializeScrollAnimationTrigger === 'function') initializeScrollAnimationTrigger(html.innerHTML); } + /** + * Render product grid container with new HTML. + * @param {string} html - The HTML string to render the product grid container with. + */ static renderProductGridContainer(html) { document.getElementById('ProductGridContainer').innerHTML = new DOMParser() .parseFromString(html, 'text/html') @@ -92,6 +137,10 @@ class FacetFiltersForm extends HTMLElement { }); } + /** + * Update product count and set loading state. + * @param {string} html - HTML string to extract product count from. + */ static renderProductCount(html) { const count = new DOMParser().parseFromString(html, 'text/html').getElementById('ProductCount').innerHTML; const container = document.getElementById('ProductCount'); @@ -108,6 +157,11 @@ class FacetFiltersForm extends HTMLElement { loadingSpinners.forEach((spinner) => spinner.classList.add('hidden')); } + /** + * Render facets with new HTML. + * @param {string} html - HTML string to render facets with. + * @param {Event} event - Event object that triggered the render. + */ static renderFilters(html, event) { const parsedHTML = new DOMParser().parseFromString(html, 'text/html'); const facetDetailsElementsFromFetch = parsedHTML.querySelectorAll( @@ -176,6 +230,10 @@ class FacetFiltersForm extends HTMLElement { } } + /** + * Render active facets section with new HTML. + * @param {string} html - HTML string to render active facets with. + */ static renderActiveFacets(html) { const activeFacetElementSelectors = ['.active-facets-mobile', '.active-facets-desktop']; @@ -188,6 +246,11 @@ class FacetFiltersForm extends HTMLElement { FacetFiltersForm.toggleActiveFacets(false); } + /** + * Render additional elements with new HTML. + * Update sorting, and mobile facet elements. + * @param {string} html - HTML string to render additional elements with. + */ static renderAdditionalElements(html) { const mobileElementSelectors = ['.mobile-facets__open', '.mobile-facets__count', '.sorting']; @@ -199,6 +262,11 @@ class FacetFiltersForm extends HTMLElement { document.getElementById('FacetFiltersFormMobile').closest('menu-drawer').bindEvents(); } + /** + * Render facet product counts with new HTML. + * @param {Element} source - Source element to get counts from. + * @param {Element} target - Target element to render counts to. + */ static renderCounts(source, target) { const targetSummary = target.querySelector('.facets__summary'); const sourceSummary = source.querySelector('.facets__summary'); @@ -229,6 +297,11 @@ class FacetFiltersForm extends HTMLElement { } } + /** + * Render mobile facet product counts with new HTML. + * @param {Element} source - Source element to get counts from. + * @param {Element} target - Target element to render counts to. + */ static renderMobileCounts(source, target) { const targetFacetsList = target.querySelector('.mobile-facets__list'); const sourceFacetsList = source.querySelector('.mobile-facets__list'); @@ -238,10 +311,18 @@ class FacetFiltersForm extends HTMLElement { } } + /** + * Update URL hash with search parameters. + * @param {string} searchParams - The search parameters to update the URL hash with. + */ static updateURLHash(searchParams) { history.pushState({ searchParams }, '', `${window.location.pathname}${searchParams && '?'.concat(searchParams)}`); } + /** + * Array of sections to update with facets. + * @returns {Array} Array of section IDs to update with facets. + */ static getSections() { return [ { @@ -250,22 +331,40 @@ class FacetFiltersForm extends HTMLElement { ]; } + /** + * Create search parameters from form data. + * @param {HTMLFormElement} form - Form element to create search parameters from. + * @returns {string} Search parameters string. + */ createSearchParams(form) { const formData = new FormData(form); return new URLSearchParams(formData).toString(); } + /** + * Submit form and update page with provided search parameters. + * @param {string} searchParams - Search parameters to submit form with. + * @param {Event} event - Event object that triggered the form submission. + */ onSubmitForm(searchParams, event) { FacetFiltersForm.renderPage(searchParams, event); } + /** + * Submit handler for facet filters form. + * Prevents default form submission and triggers page updates based on form data. + * Handles both mobile and desktop form submissions. + * @param {SubmitEvent} event - Event object that triggered the submission. + */ onSubmitHandler(event) { event.preventDefault(); const sortFilterForms = document.querySelectorAll('facet-filters-form form'); + // TODO: Refactor to remove deprecated `srcElement` property. if (event.srcElement.className == 'mobile-facets__checkbox') { const searchParams = this.createSearchParams(event.target.closest('form')); this.onSubmitForm(searchParams, event); } else { + /** @type {Array} */ const forms = []; const isMobile = event.target.closest('form').id === 'FacetFiltersFormMobile'; @@ -282,6 +381,11 @@ class FacetFiltersForm extends HTMLElement { } } + /** + * Handle click event on active filter. + * Remove active filter and trigger page update. + * @param {MouseEvent} event - Event object that triggered the click. + */ onActiveFilterClick(event) { event.preventDefault(); FacetFiltersForm.toggleActiveFacets(); @@ -293,12 +397,17 @@ class FacetFiltersForm extends HTMLElement { } } +/** @type {Array<{html: string, url: string}>} */ FacetFiltersForm.filterData = []; FacetFiltersForm.searchParamsInitial = window.location.search.slice(1); FacetFiltersForm.searchParamsPrev = window.location.search.slice(1); customElements.define('facet-filters-form', FacetFiltersForm); FacetFiltersForm.setListeners(); +/** + * Price Range custom element class. + * @extends HTMLElement + */ class PriceRange extends HTMLElement { constructor() { super(); @@ -309,11 +418,19 @@ class PriceRange extends HTMLElement { this.setMinAndMaxValues(); } + /** + * On range change event handler. Trigger adjustments to valid values. + * @param {Event} event - Event object that triggered the change. + */ onRangeChange(event) { this.adjustToValidValues(event.currentTarget); this.setMinAndMaxValues(); } + /** + * Prevent invalid characters from being entered on key press. + * @param {KeyboardEvent} event - Keyboard event object. + */ onKeyDown(event) { if (event.metaKey) return; @@ -321,6 +438,9 @@ class PriceRange extends HTMLElement { if (!event.key.match(pattern)) event.preventDefault(); } + /** + * Set min and max values for price range inputs. + */ setMinAndMaxValues() { const inputs = this.querySelectorAll('input'); const minInput = inputs[0]; @@ -331,6 +451,9 @@ class PriceRange extends HTMLElement { if (maxInput.value === '') minInput.setAttribute('data-max', maxInput.getAttribute('data-max')); } + /** + * Adjust input values to be within valid range. + */ adjustToValidValues(input) { const value = Number(input.value); const min = Number(input.getAttribute('data-min')); @@ -343,6 +466,10 @@ class PriceRange extends HTMLElement { customElements.define('price-range', PriceRange); +/** + * Facet Remove custom element class. + * @extends HTMLElement + */ class FacetRemove extends HTMLElement { constructor() { super(); @@ -355,6 +482,10 @@ class FacetRemove extends HTMLElement { }); } + /** + * Close active filter. + * @param {Event} event - Event object that triggered the facet close. + */ closeFilter(event) { event.preventDefault(); const form = this.closest('facet-filters-form') || document.querySelector('facet-filters-form'); diff --git a/assets/global.js b/assets/global.js index ce13bf6514a..dd37ea1493b 100644 --- a/assets/global.js +++ b/assets/global.js @@ -1,3 +1,8 @@ +/** + * Retrieves all focusable elements within a given container. + * @param {HTMLElement} container - The container element to search within. + * @returns {Array} - An array of focusable elements. + */ function getFocusableElements(container) { return Array.from( container.querySelectorAll( @@ -6,6 +11,9 @@ function getFocusableElements(container) { ); } +/** + * The SectionId class provides utility methods for working with section IDs. + */ class SectionId { static #separator = '__'; @@ -25,6 +33,10 @@ class SectionId { } } +/** + * Utility class for updating HTML nodes. + * @name HTMLUpdateUtility + */ class HTMLUpdateUtility { /** * Used to swap an HTML node with a new node. @@ -68,6 +80,7 @@ class HTMLUpdateUtility { } } +// Set role and aria-expanded attributes for each summary element document.querySelectorAll('[id^="Details-"] summary').forEach((summary) => { summary.setAttribute('role', 'button'); summary.setAttribute('aria-expanded', summary.parentNode.hasAttribute('open')); @@ -80,12 +93,18 @@ document.querySelectorAll('[id^="Details-"] summary').forEach((summary) => { event.currentTarget.setAttribute('aria-expanded', !event.currentTarget.closest('details').hasAttribute('open')); }); + // Add keyup event listener to handle ESC key if (summary.closest('header-drawer, menu-drawer')) return; summary.parentElement.addEventListener('keyup', onKeyUpEscape); }); const trapFocusHandlers = {}; +/** + * Traps the focus within a specified container element. + * @param {HTMLElement} container - The container element to trap the focus within. + * @param {HTMLElement} [elementToFocus=container] - The element to focus initially within the container. Defaults to the container itself. + */ function trapFocus(container, elementToFocus = container) { var elements = getFocusableElements(container); var first = elements[0]; @@ -139,6 +158,10 @@ try { focusVisiblePolyfill(); } +/** + * Polyfill for managing focus visibility. + * This function adds a polyfill for managing focus visibility. It listens for keyboard events and mouse events to determine if an element should have a "focused" class applied to it. The "focused" class is added to the currently focused element when it receives focus through keyboard navigation, and is removed when it loses focus or when a mouse click occurs. + */ function focusVisiblePolyfill() { const navKeys = [ 'ARROWUP', @@ -181,6 +204,9 @@ function focusVisiblePolyfill() { ); } +/** + * Pauses all media elements on the page. + */ function pauseAllMedia() { document.querySelectorAll('.js-youtube').forEach((video) => { video.contentWindow.postMessage('{"event":"command","func":"' + 'pauseVideo' + '","args":""}', '*'); @@ -194,6 +220,10 @@ function pauseAllMedia() { }); } +/** + * Removes the trap focus behavior from the document. + * @param {HTMLElement} [elementToFocus=null] - The element to focus after removing the trap focus behavior. + */ function removeTrapFocus(elementToFocus = null) { document.removeEventListener('focusin', trapFocusHandlers.focusin); document.removeEventListener('focusout', trapFocusHandlers.focusout); @@ -202,6 +232,10 @@ function removeTrapFocus(elementToFocus = null) { if (elementToFocus) elementToFocus.focus(); } +/** + * Handles the key up event for the "Escape" key on summary elements within details elements. + * @param {KeyboardEvent} event - The key up event object. + */ function onKeyUpEscape(event) { if (event.code.toUpperCase() !== 'ESCAPE') return; @@ -214,6 +248,11 @@ function onKeyUpEscape(event) { summaryElement.focus(); } +/** + * Custom quantity input element. + * @class QuantityInput + * @extends HTMLElement + */ class QuantityInput extends HTMLElement { constructor() { super(); @@ -225,6 +264,9 @@ class QuantityInput extends HTMLElement { ); } + /** + * @type {Function | undefined} + */ quantityUpdateUnsubscriber = undefined; connectedCallback() { @@ -279,6 +321,12 @@ class QuantityInput extends HTMLElement { customElements.define('quantity-input', QuantityInput); +/** + * Debounces a function to be executed after a specified wait time. + * @param {Function} fn - The function to be debounced. + * @param {number} wait - The wait time in milliseconds. + * @returns {Function} The debounced function. + */ function debounce(fn, wait) { let t; return (...args) => { @@ -287,6 +335,12 @@ function debounce(fn, wait) { }; } +/** + * Throttles the execution of a function. + * @param {Function} fn - The function to be throttled. + * @param {number} delay - The delay in milliseconds. + * @returns {Function} The throttled function. + */ function throttle(fn, delay) { let lastCall = 0; return function (...args) { @@ -299,6 +353,18 @@ function throttle(fn, delay) { }; } +/** + * @typedef {Object} FetchConfig + * @property {'POST' | 'GET' | 'PUT' | 'DELETE'} method - HTTP method for request. + * @property {Object} headers - Headers for request. + * @property {Object} [body] - Body for request. + * @property {Array>} [parameters] - Parameters for request. + */ +/** + * Utility function to fetch a response from a URL. + * @param {string} [type='json'] - The type of response to accept. Defaults to 'json'. + * @returns {FetchConfig} The configuration object for the request. + */ function fetchConfig(type = 'json') { return { method: 'POST', @@ -308,18 +374,27 @@ function fetchConfig(type = 'json') { /* * Shopify Common JS - * + * ----------------- */ + if (typeof window.Shopify == 'undefined') { window.Shopify = {}; } - +/** + * Bind a function to a context. + * @param {Function} fn - Function to bind. + * @param {Object} scope - Context to bind function to. + */ Shopify.bind = function (fn, scope) { return function () { return fn.apply(scope, arguments); }; }; - +/** + * Set value of select element based on its value or innerHTML. + * @param {HTMLSelectElement} selector - Select element to set value for. + * @param {string} value - Value to set for select element. + */ Shopify.setSelectorByValue = function (selector, value) { for (var i = 0, count = selector.options.length; i < count; i++) { var option = selector.options[i]; @@ -329,13 +404,22 @@ Shopify.setSelectorByValue = function (selector, value) { } } }; - +/** + * Add an event listener to an element. + * @param {HTMLElement} target - Target element to add event listener to. + * @param {string} eventName - Name of event to listen for. + * @param {Function} callback - Function to call when event is triggered. + */ Shopify.addListener = function (target, eventName, callback) { target.addEventListener ? target.addEventListener(eventName, callback, false) : target.attachEvent('on' + eventName, callback); }; - +/** + * PostLink + * @param {string} path - URL to post to. + * @param {FetchConfig} options - Additional options for post. + */ Shopify.postLink = function (path, options) { options = options || {}; var method = options['method'] || 'post'; @@ -356,10 +440,18 @@ Shopify.postLink = function (path, options) { form.submit(); document.body.removeChild(form); }; - +/** + * CountryProvinceSelector + * @param {string} country_domid - ID of country selector element. + * @param {string} province_domid - ID of province selector element. + * @param {Object} options - Additional options for selector. + */ Shopify.CountryProvinceSelector = function (country_domid, province_domid, options) { + /** @type {HTMLSelectElement} */ this.countryEl = document.getElementById(country_domid); + /** @type {HTMLSelectElement} */ this.provinceEl = document.getElementById(province_domid); + /** @type {HTMLElement} */ this.provinceContainer = document.getElementById(options['hideElement'] || province_domid); Shopify.addListener(this.countryEl, 'change', Shopify.bind(this.countryHandler, this)); @@ -367,14 +459,15 @@ Shopify.CountryProvinceSelector = function (country_domid, province_domid, optio this.initCountry(); this.initProvince(); }; - Shopify.CountryProvinceSelector.prototype = { + /** Initialize country selector */ initCountry: function () { var value = this.countryEl.getAttribute('data-default'); Shopify.setSelectorByValue(this.countryEl, value); this.countryHandler(); }, + /** Initialize province selector */ initProvince: function () { var value = this.provinceEl.getAttribute('data-default'); if (value && this.provinceEl.options.length > 0) { @@ -382,9 +475,17 @@ Shopify.CountryProvinceSelector.prototype = { } }, + /** + * Handle country select event. + * Update province dropdown based on selected country. + * @param {Event} e - Event object. + */ countryHandler: function (e) { + /** @type {HTMLOptionElement} */ var opt = this.countryEl.options[this.countryEl.selectedIndex]; + /** @type {string} */ var raw = opt.getAttribute('data-provinces'); + /** @type {Array.>} */ var provinces = JSON.parse(raw); this.clearOptions(this.provinceEl); @@ -402,12 +503,21 @@ Shopify.CountryProvinceSelector.prototype = { } }, + /** + * Clear all options from a select box. + * @param {HTMLSelectElement} selector - The select element to clear. + */ clearOptions: function (selector) { while (selector.firstChild) { selector.removeChild(selector.firstChild); } }, + /** + * Set options for select element. + * @param {HTMLSelectElement} selector - Select element to set options for. + * @param {Array.} values - Values to set as options. + */ setOptions: function (selector, values) { for (var i = 0, count = values.length; i < values.length; i++) { var opt = document.createElement('option'); @@ -418,6 +528,10 @@ Shopify.CountryProvinceSelector.prototype = { }, }; +/** + * Custom element for menu drawer. + * @extends HTMLElement + */ class MenuDrawer extends HTMLElement { constructor() { super(); @@ -449,6 +563,10 @@ class MenuDrawer extends HTMLElement { : this.closeSubmenu(openDetailsElement); } + /** + * Handles the click event on the summary element. Expands or collapses the nested details element w/ focus management. + * @param {MouseEvent} event - The click event object. + */ onSummaryClick(event) { const summaryElement = event.currentTarget; const detailsElement = summaryElement.parentNode; @@ -480,6 +598,11 @@ class MenuDrawer extends HTMLElement { } } + /** + * Opens the menu drawer. + * + * @param {HTMLElement} summaryElement - The summary element that triggered the opening of the menu drawer. + */ openMenuDrawer(summaryElement) { setTimeout(() => { this.mainDetailsToggle.classList.add('menu-opening'); @@ -489,6 +612,12 @@ class MenuDrawer extends HTMLElement { document.body.classList.add(`overflow-hidden-${this.dataset.breakpoint}`); } + /** + * Closes the menu drawer. + * + * @param {Event} event - The event that triggered the function. + * @param {boolean} [elementToFocus=false] - The element to focus after closing the menu drawer. + */ closeMenuDrawer(event, elementToFocus = false) { if (event === undefined) return; @@ -514,11 +643,19 @@ class MenuDrawer extends HTMLElement { }); } + /** + * Handles the click event on the close button. Closes the submenu. + * @param {MouseEvent} event - The click event object. + */ onCloseButtonClick(event) { const detailsElement = event.currentTarget.closest('details'); this.closeSubmenu(detailsElement); } + /** + * Closes the submenu. + * @param {HTMLElement} detailsElement - The details element to close. + */ closeSubmenu(detailsElement) { const parentMenuElement = detailsElement.closest('.submenu-open'); parentMenuElement && parentMenuElement.classList.remove('submenu-open'); @@ -528,9 +665,18 @@ class MenuDrawer extends HTMLElement { this.closeAnimation(detailsElement); } + /** + * Closes the details element with an animation. + * + * @param {HTMLElement} detailsElement - The details element to be closed. + */ closeAnimation(detailsElement) { let animationStart; + /** + * Handle animation frame update. + * @param {DOMHighResTimeStamp} time - The current time in milliseconds. + */ const handleAnimation = (time) => { if (animationStart === undefined) { animationStart = time; @@ -554,11 +700,19 @@ class MenuDrawer extends HTMLElement { customElements.define('menu-drawer', MenuDrawer); +/** + * Custom element for header drawer. + * @extends MenuDrawer + */ class HeaderDrawer extends MenuDrawer { constructor() { super(); } + /** + * Opens the menu drawer. + * @param {HTMLElement} summaryElement - The summary element that triggered the opening of the menu drawer. + */ openMenuDrawer(summaryElement) { this.header = this.header || document.querySelector('.section-header'); this.borderOffset = @@ -579,6 +733,13 @@ class HeaderDrawer extends MenuDrawer { document.body.classList.add(`overflow-hidden-${this.dataset.breakpoint}`); } + /** + * Closes the menu drawer and removes the 'menu-open' class from the header. + * Also removes the 'resize' event listener from the window. + * + * @param {Event} event - The event object. + * @param {HTMLElement} elementToFocus - The element to focus after closing the menu drawer. + */ closeMenuDrawer(event, elementToFocus) { if (!elementToFocus) return; super.closeMenuDrawer(event, elementToFocus); @@ -598,6 +759,10 @@ class HeaderDrawer extends MenuDrawer { customElements.define('header-drawer', HeaderDrawer); +/** + * Modal dialog custom element class. + * @extends HTMLElement + */ class ModalDialog extends HTMLElement { constructor() { super(); @@ -622,8 +787,14 @@ class ModalDialog extends HTMLElement { document.body.appendChild(this); } + + /** + * Show modal dialog. + * @param {HTMLElement} opener - Element that triggered opening of modal. + */ show(opener) { this.openedBy = opener; + /** @type {DeferredMedia} */ const popup = this.querySelector('.template-popup'); document.body.classList.add('overflow-hidden'); this.setAttribute('open', ''); @@ -632,6 +803,7 @@ class ModalDialog extends HTMLElement { window.pauseAllMedia(); } + /** Hide modal dialog. */ hide() { document.body.classList.remove('overflow-hidden'); document.body.dispatchEvent(new CustomEvent('modalClosed')); @@ -689,6 +861,10 @@ class ModalOpener extends HTMLElement { } customElements.define('modal-opener', ModalOpener); +/** + * Custom element for deferred media loading. + * @extends HTMLElement + */ class DeferredMedia extends HTMLElement { constructor() { super(); @@ -697,6 +873,10 @@ class DeferredMedia extends HTMLElement { poster.addEventListener('click', this.loadContent.bind(this)); } + /** + * Load content for deferred media element. + * @param {boolean} [focus=true] - Whether to focus element after loading content. + */ loadContent(focus = true) { window.pauseAllMedia(); if (!this.getAttribute('loaded')) { @@ -716,6 +896,10 @@ class DeferredMedia extends HTMLElement { customElements.define('deferred-media', DeferredMedia); +/** + * Custom slider component element. + * @extends HTMLElement + */ class SliderComponent extends HTMLElement { constructor() { super(); @@ -754,6 +938,9 @@ class SliderComponent extends HTMLElement { this.initPages(); } + /** + * Update slider state + */ update() { // Temporarily prevents unneeded updates resulting from variant changes // This should be refactored as part of https://github.com/Shopify/dawn/issues/2057 @@ -769,6 +956,7 @@ class SliderComponent extends HTMLElement { if (this.currentPage != previousPage) { this.dispatchEvent( + // TODO: Add jsdoc type for slideChanged event new CustomEvent('slideChanged', { detail: { currentPage: this.currentPage, @@ -793,11 +981,21 @@ class SliderComponent extends HTMLElement { } } + /** + * Check if slide is visible. + * @param {HTMLElement} element - The slide element to check. + * @param {number} [offset=0] - The offset value. + * @returns {boolean} True if the slide is visible, false otherwise. + */ isSlideVisible(element, offset = 0) { const lastVisibleSlide = this.slider.clientWidth + this.slider.scrollLeft - offset; return element.offsetLeft + element.clientWidth <= lastVisibleSlide && element.offsetLeft >= this.slider.scrollLeft; } + /** + * Handles navigation button click events. + * @param {MouseEvent} event - The click event object. + */ onButtonClick(event) { event.preventDefault(); const step = event.currentTarget.dataset.step || 1; @@ -808,6 +1006,10 @@ class SliderComponent extends HTMLElement { this.setSlidePosition(this.slideScrollPosition); } + /** + * Sets the scroll position of the slider. + * @param {number} position - The position to scroll to. + */ setSlidePosition(position) { this.slider.scrollTo({ left: position, @@ -817,29 +1019,42 @@ class SliderComponent extends HTMLElement { customElements.define('slider-component', SliderComponent); +/** + * Custom element for slideshow component. + * @extends SliderComponent + */ class SlideshowComponent extends SliderComponent { constructor() { super(); + /** @type {HTMLElement | null} */ this.sliderControlWrapper = this.querySelector('.slider-buttons'); + /** @type {boolean} */ this.enableSliderLooping = true; + /** @type {boolean} */ + this.autoplayButtonIsSetToPlay = false; if (!this.sliderControlWrapper) return; + /** @type {HTMLElement | null} */ this.sliderFirstItemNode = this.slider.querySelector('.slideshow__slide'); if (this.sliderItemsToShow.length > 0) this.currentPage = 1; + /** @type {HTMLElement | null} */ this.announcementBarSlider = this.querySelector('.announcement-bar-slider'); // Value below should match --duration-announcement-bar CSS value this.announcerBarAnimationDelay = this.announcementBarSlider ? 250 : 0; + /** @type {Array} */ this.sliderControlLinksArray = Array.from(this.sliderControlWrapper.querySelectorAll('.slider-counter__link')); this.sliderControlLinksArray.forEach((link) => link.addEventListener('click', this.linkToSlide.bind(this))); this.slider.addEventListener('scroll', this.setSlideVisibility.bind(this)); this.setSlideVisibility(); if (this.announcementBarSlider) { + /** @type {boolean} */ this.announcementBarArrowButtonWasClicked = false; + /** @type {boolean} */ this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); this.reducedMotion.addEventListener('change', () => { if (this.slider.getAttribute('data-autoplay') === 'true') this.setAutoPlay(); @@ -876,6 +1091,10 @@ class SlideshowComponent extends SliderComponent { } } + /** + * Handles navigation button click events. + * @param {MouseEvent} event - The click event object. + */ onButtonClick(event) { super.onButtonClick(event); this.wasClicked = true; @@ -900,6 +1119,10 @@ class SlideshowComponent extends SliderComponent { this.applyAnimationToAnnouncementBar(event.currentTarget.name); } + /** + * Sets the scroll position of the slider. + * @param {number} position - The position to scroll to. + */ setSlidePosition(position) { if (this.setPositionTimeout) clearTimeout(this.setPositionTimeout); this.setPositionTimeout = setTimeout(() => { @@ -909,6 +1132,9 @@ class SlideshowComponent extends SliderComponent { }, this.announcerBarAnimationDelay); } + /** + * Update slider state + */ update() { super.update(); this.sliderControlButtons = this.querySelectorAll('.slider-counter__link'); @@ -924,12 +1150,17 @@ class SlideshowComponent extends SliderComponent { this.sliderControlButtons[this.currentPage - 1].setAttribute('aria-current', true); } + /** Toggle autoplay state */ autoPlayToggle() { this.togglePlayButtonState(this.autoplayButtonIsSetToPlay); this.autoplayButtonIsSetToPlay ? this.pause() : this.play(); this.autoplayButtonIsSetToPlay = !this.autoplayButtonIsSetToPlay; } + /** + * Handle focus out event. Toggle autoplay depending on button focus. + * @param {FocusEvent} event - The focus out event object. + */ focusOutHandling(event) { if (this.sliderAutoplayButton) { const focusedOnAutoplayButton = @@ -941,6 +1172,10 @@ class SlideshowComponent extends SliderComponent { } } + /** + * Handle focus in event. Toggle autoplay depending on button focus. + * @param {FocusEvent} event - The focus in event object. + */ focusInHandling(event) { if (this.sliderAutoplayButton) { const focusedOnAutoplayButton = @@ -955,17 +1190,23 @@ class SlideshowComponent extends SliderComponent { } } + /** Play slideshow */ play() { this.slider.setAttribute('aria-live', 'off'); clearInterval(this.autoplay); this.autoplay = setInterval(this.autoRotateSlides.bind(this), this.autoplaySpeed); } + /** Pause slideshow */ pause() { this.slider.setAttribute('aria-live', 'polite'); clearInterval(this.autoplay); } + /** + * Toggle the state of the play button. + * @param {boolean} pauseAutoplay - The state of the autoplay. + */ togglePlayButtonState(pauseAutoplay) { if (pauseAutoplay) { this.sliderAutoplayButton.classList.add('slideshow__autoplay--paused'); @@ -976,6 +1217,7 @@ class SlideshowComponent extends SliderComponent { } } + /** Auto rotate slides. Set position to 0 if past last slide. */ autoRotateSlides() { const slideScrollPosition = this.currentPage === this.sliderItems.length ? 0 : this.slider.scrollLeft + this.sliderItemOffset; @@ -984,7 +1226,10 @@ class SlideshowComponent extends SliderComponent { this.applyAnimationToAnnouncementBar(); } - setSlideVisibility(event) { + /** + * Set the visibility of the slides. + */ + setSlideVisibility() { this.sliderItemsToShow.forEach((item, index) => { const linkElements = item.querySelectorAll('a'); if (index === this.currentPage - 1) { @@ -1006,6 +1251,10 @@ class SlideshowComponent extends SliderComponent { this.wasClicked = false; } + /** + * Apply sliding animation to the announcement bar. + * @param {'next' | 'previous'} [button='next'] - The button direction. + */ applyAnimationToAnnouncementBar(button = 'next') { if (!this.announcementBarSlider) return; @@ -1037,6 +1286,10 @@ class SlideshowComponent extends SliderComponent { }, this.announcerBarAnimationDelay * 2); } + /** + * Navigate to specific slide based on clicked link. + * @param {Event} event - The event object. + */ linkToSlide(event) { event.preventDefault(); const slideScrollPosition = @@ -1051,6 +1304,10 @@ class SlideshowComponent extends SliderComponent { customElements.define('slideshow-component', SlideshowComponent); +/** + * Custom variant selector element. + * @extends HTMLElement + */ class VariantSelects extends HTMLElement { constructor() { super(); @@ -1071,6 +1328,10 @@ class VariantSelects extends HTMLElement { }); } + /** + * Updates the selection metadata based on the target element. + * @param {Event} event - The event object. + */ updateSelectionMetadata({ target }) { const { value, tagName } = target; @@ -1103,10 +1364,19 @@ class VariantSelects extends HTMLElement { } } + /** + * Retrieves input value for the given event target. + * @param {HTMLElement} target - The event target element. + * @returns {HTMLElement|HTMLOptionElement} The input value of the target element. + */ getInputForEventTarget(target) { return target.tagName === 'SELECT' ? target.selectedOptions[0] : target; } + /** + * Returns array of selected option values. + * @returns {Array} Array of selected option values. + */ get selectedOptionValues() { return Array.from(this.querySelectorAll('select option[selected], fieldset input:checked')).map( ({ dataset }) => dataset.optionValueId @@ -1116,6 +1386,10 @@ class VariantSelects extends HTMLElement { customElements.define('variant-selects', VariantSelects); +/** + * Product Recommendations element. + * @extends HTMLElement + */ class ProductRecommendations extends HTMLElement { observer = undefined; @@ -1127,6 +1401,10 @@ class ProductRecommendations extends HTMLElement { this.initializeRecommendations(this.dataset.productId); } + /** + * Initialize intersection observer to load recommendations when in view. + * @param {string} productId - The product ID to fetch recommendations for. + */ initializeRecommendations(productId) { this.observer?.unobserve(this); this.observer = new IntersectionObserver( @@ -1140,6 +1418,10 @@ class ProductRecommendations extends HTMLElement { this.observer.observe(this); } + /** + * Load recommendations for the given product ID and create necessary wrappers and elements. + * @param {string} productId - The product ID to fetch recommendations + */ loadRecommendations(productId) { fetch(`${this.dataset.url}&product_id=${productId}§ion_id=${this.dataset.sectionId}`) .then((response) => response.text()) @@ -1168,6 +1450,11 @@ class ProductRecommendations extends HTMLElement { customElements.define('product-recommendations', ProductRecommendations); +/** + * Account icon element. + * If user is signed in, it will replace the default icon with the user avatar. + * @extends HTMLElement + */ class AccountIcon extends HTMLElement { constructor() { super(); @@ -1179,6 +1466,11 @@ class AccountIcon extends HTMLElement { document.addEventListener('storefront:signincompleted', this.handleStorefrontSignInCompleted.bind(this)); } + /** + * Replace the default icon with the user avatar. + * @param {CustomEvent} event - The custom 'storefront:signincompleted' event object. + * @todo Document the custom event object. + */ handleStorefrontSignInCompleted(event) { if (event?.detail?.avatar) { this.icon?.replaceWith(event.detail.avatar.cloneNode()); @@ -1188,6 +1480,10 @@ class AccountIcon extends HTMLElement { customElements.define('account-icon', AccountIcon); +/** + * Bulk add element. + * @extends HTMLElement + */ class BulkAdd extends HTMLElement { constructor() { super(); @@ -1196,6 +1492,11 @@ class BulkAdd extends HTMLElement { this.ids = []; } + /** + * Start queue for bulk-adding products. + * @param {string} id - Product ID. + * @param {number} quantity - Quantity to add. + */ startQueue(id, quantity) { this.queue.push({ id, quantity }); const interval = setInterval(() => { @@ -1209,6 +1510,10 @@ class BulkAdd extends HTMLElement { }, 250); } + /** + * Send request to add products to cart. + * @param {Array<{id: string, quantity: number}>} queue - Queue of products to add. + */ sendRequest(queue) { this.requestStarted = true; const items = {}; @@ -1220,12 +1525,22 @@ class BulkAdd extends HTMLElement { quickBulkElement.updateMultipleQty(items); } + /** + * Reset the product quantity input field. + * @param {string} id - Product ID. + */ resetQuantityInput(id) { const input = this.querySelector(`#Quantity-${id}`); input.value = input.getAttribute('value'); this.isEnterPressed = false; } + /** + * Set validity of the product quantity input field. + * @param {Event} event - The event object. + * @param {number} index - Product index. + * @param {string} message - Validity message. + */ setValidity(event, index, message) { event.target.setCustomValidity(message); event.target.reportValidity(); @@ -1233,6 +1548,11 @@ class BulkAdd extends HTMLElement { event.target.select(); } + /** + * Validate the product quantity input field. + * Set custom validity message if the input value is invalid. + * @param {Event} event - The event object. + */ validateQuantity(event) { const inputValue = parseInt(event.target.value); const index = event.target.dataset.index; @@ -1250,6 +1570,10 @@ class BulkAdd extends HTMLElement { } } + /** + * Get URL for sections, used to update sections with sections api. + * @returns {string} URL for sections. + */ getSectionsUrl() { if (window.pageNumber) { return `${window.location.pathname}?page=${window.pageNumber}`; @@ -1258,6 +1582,12 @@ class BulkAdd extends HTMLElement { } } + /** + * Gets the specified section's inner HTML from a request. + * @param {string} html - The HTML string. + * @param {string} selector - The CSS selector to query within the HTML string param. + * @returns {string} The inner HTML of the requested section. + */ getSectionInnerHTML(html, selector) { return new DOMParser().parseFromString(html, 'text/html').querySelector(selector).innerHTML; } diff --git a/assets/localization-form.js b/assets/localization-form.js index 3eff4e41d4b..adc2e447559 100644 --- a/assets/localization-form.js +++ b/assets/localization-form.js @@ -1,11 +1,31 @@ +/** + * @typedef {Object} LocalizationFormElements + * @property {HTMLInputElement} input - Hidden input element for selected locale code. + * @property {HTMLButtonElement} button - Button to open country selector. + * @property {HTMLElement} panel - Country selector panel/popup wrapper. + * @property {HTMLInputElement} search - Input element for country search. + * @property {HTMLButtonElement} closeButton - Button to close country selector. + * @property {HTMLButtonElement} resetButton - Button to reset country search. + * @property {HTMLElement} searchIcon - Search icon element. + * @property {HTMLElement} liveRegion - Live region for search results count. + */ + if (!customElements.get('localization-form')) { customElements.define( 'localization-form', + /** + * Localization form custom element class. + * @extends HTMLElement + */ class LocalizationForm extends HTMLElement { constructor() { super(); this.mql = window.matchMedia('(min-width: 750px)'); + + /** @type {HTMLElement} */ this.header = document.querySelector('.header-wrapper'); + + /** @type {LocalizationFormElements} */ this.elements = { input: this.querySelector('input[name="locale_code"], input[name="country_code"]'), button: this.querySelector('button.localization-form__select'), @@ -38,6 +58,7 @@ if (!customElements.get('localization-form')) { this.querySelectorAll('a').forEach((item) => item.addEventListener('click', this.onItemClick.bind(this))); } + /** Directly hide country selector panel. */ hidePanel() { this.elements.button.setAttribute('aria-expanded', 'false'); this.elements.panel.setAttribute('hidden', true); @@ -51,11 +72,17 @@ if (!customElements.get('localization-form')) { this.header.preventHide = false; } + /** + * Handle keydown event on container element. + * Key up/down to navigate through country list. + * @param {KeyboardEvent} event - Keyboard event object. + */ onContainerKeyDown(event) { const focusableItems = Array.from(this.querySelectorAll('a')).filter( (item) => !item.parentElement.classList.contains('hidden') ); let focusedItemIndex = focusableItems.findIndex((item) => item === document.activeElement); + /** @type {HTMLAnchorElement} */ let itemToFocus; switch (event.code.toUpperCase()) { @@ -85,6 +112,12 @@ if (!customElements.get('localization-form')) { }); } + /** + * Handle keyup event on container element. + * Escape key to close country selector. + * Space key to open country selector. + * @param {KeyboardEvent} event - Keyboard event object. + */ onContainerKeyUp(event) { event.preventDefault(); @@ -102,6 +135,10 @@ if (!customElements.get('localization-form')) { } } + /** + * Select country on item click and submit form. + * @param {MouseEvent} event - Mouse event object. + */ onItemClick(event) { event.preventDefault(); const form = this.querySelector('form'); @@ -109,6 +146,7 @@ if (!customElements.get('localization-form')) { if (form) form.submit(); } + /** Open country selector panel. */ openSelector() { this.elements.button.focus(); this.elements.panel.toggleAttribute('hidden'); @@ -128,6 +166,10 @@ if (!customElements.get('localization-form')) { document.querySelector('.menu-drawer').classList.add('country-selector-open'); } + /** + * Close country selector panel. + * @param {FocusEvent} event - Focus event object. + */ closeSelector(event) { if ( event.target.classList.contains('country-selector__overlay') || @@ -138,6 +180,11 @@ if (!customElements.get('localization-form')) { } } + /** + * Format string for search normalization. + * @param {string} str - String to normalize. + * @returns {string} Normalized string. + */ normalizeString(str) { return str .normalize('NFD') @@ -145,6 +192,7 @@ if (!customElements.get('localization-form')) { .toLowerCase(); } + /** Filter countries based on search input value. */ filterCountries() { const searchValue = this.normalizeString(this.elements.search.value); const popularCountries = this.querySelector('.popular-countries'); @@ -179,6 +227,7 @@ if (!customElements.get('localization-form')) { this.querySelector('.country-selector__list').scrollTop = 0; } + /** Reset country search filter. */ resetFilter(event) { event.stopPropagation(); this.elements.search.value = ''; @@ -186,16 +235,22 @@ if (!customElements.get('localization-form')) { this.elements.search.focus(); } + /** Hide search icon on search input focus. */ onSearchFocus() { this.elements.searchIcon.classList.add('country-filter__search-icon--hidden'); } + /** Show search icon on search input blur. */ onSearchBlur() { if (!this.elements.search.value) { this.elements.searchIcon.classList.remove('country-filter__search-icon--hidden'); } } + /** + * Prevent form submission on search input keydown. + * @param {KeyboardEvent} event - Keyboard event object. + */ onSearchKeyDown(event) { if (event.code.toUpperCase() === 'ENTER') { event.preventDefault(); diff --git a/assets/magnify.js b/assets/magnify.js index 11359d2e1a9..c0186ab2161 100644 --- a/assets/magnify.js +++ b/assets/magnify.js @@ -1,8 +1,13 @@ -// create a container and set the full-size image as its background +/** + * Create a container and set full-size image as its background. + * @param {HTMLImageElement} image - Image to magnify. + * @returns {HTMLDivElement} New overlay container. + */ function createOverlay(image) { const overlayImage = document.createElement('img'); overlayImage.setAttribute('src', `${image.src}`); - overlay = document.createElement('div'); + /** @type {HTMLDivElement} */ + overlay = document.createElement('div'); // TODO: Don't use global variable prepareOverlay(overlay, overlayImage); image.style.opacity = '50%'; @@ -17,6 +22,11 @@ function createOverlay(image) { return overlay; } +/** + * Prepare overlay container classes and styles. + * @param {HTMLDivElement} container - Overlay container element. + * @param {HTMLImageElement} image - Overlay image element. + */ function prepareOverlay(container, image) { container.setAttribute('class', 'image-magnify-full-size'); container.setAttribute('aria-hidden', 'true'); @@ -24,14 +34,25 @@ function prepareOverlay(container, image) { container.style.backgroundColor = 'var(--gradient-background)'; } +/** + * Toggle the loading spinner. + * @param {HTMLImageElement} image - Image to toggle loading spinner for. + */ function toggleLoadingSpinner(image) { const loadingSpinner = image.parentElement.parentElement.querySelector(`.loading__spinner`); loadingSpinner.classList.toggle('hidden'); } +/** + * Move the background image with the mouse hover position. + * @param {HTMLImageElement} image - Image to magnify. + * @param {MouseEvent} event - Mouse event. + * @param {number} zoomRatio - Zoom ratio. + */ function moveWithHover(image, event, zoomRatio) { // calculate mouse position const ratio = image.height / image.width; + /** @type {HTMLElement} */ const container = event.target.getBoundingClientRect(); const xPosition = event.clientX - container.left; const yPosition = event.clientY - container.top; @@ -43,6 +64,11 @@ function moveWithHover(image, event, zoomRatio) { overlay.style.backgroundSize = `${image.width * zoomRatio}px`; } +/** + * Magnify image. Create overlay and move background image with mouse hover. + * @param {HTMLImageElement} image - Image to magnify. + * @param {number} zoomRatio - Zoom ratio. + */ function magnify(image, zoomRatio) { const overlay = createOverlay(image); overlay.onclick = () => overlay.remove(); @@ -50,6 +76,11 @@ function magnify(image, zoomRatio) { overlay.onmouseleave = () => overlay.remove(); } +/** + * Enable zoom on image hover. + * For each image with class 'image-magnify-hover', add event listeners to magnify and move with hover. + * @param {number} zoomRatio - Zoom ratio. + */ function enableZoomOnHover(zoomRatio) { const images = document.querySelectorAll('.image-magnify-hover'); images.forEach((image) => { diff --git a/assets/main-search.js b/assets/main-search.js index 4e0e432ff07..089245adbf8 100644 --- a/assets/main-search.js +++ b/assets/main-search.js @@ -1,11 +1,20 @@ +/** + * Main search page form custom element class. + * @extends SearchForm + */ class MainSearch extends SearchForm { constructor() { super(); + /** @type {NodeListOf} */ this.allSearchInputs = document.querySelectorAll('input[type="search"]'); this.setupEventListeners(); } + /** + * Set up and bind events for search inputs. + */ setupEventListeners() { + /** @type {Array} */ let allSearchForms = []; this.allSearchInputs.forEach((input) => allSearchForms.push(input.form)); this.input.addEventListener('focus', this.onInputFocus.bind(this)); @@ -14,6 +23,10 @@ class MainSearch extends SearchForm { this.allSearchInputs.forEach((input) => input.addEventListener('input', this.onInput.bind(this))); } + /** + * Handle form reset event. Reset all search inputs. + * @param {Event} event - Reset event object. + */ onFormReset(event) { super.onFormReset(event); if (super.shouldResetForm()) { @@ -21,11 +34,18 @@ class MainSearch extends SearchForm { } } + /** + * Handle input change event. Sync all search input values. + * @param {InputEvent} event - Input event object + */ onInput(event) { const target = event.target; this.keepInSync(target.value, target); } + /** + * Handle input focus event. Scroll into view on mobile. + */ onInputFocus() { const isSmallScreen = window.innerWidth < 750; if (isSmallScreen) { @@ -33,6 +53,11 @@ class MainSearch extends SearchForm { } } + /** + * Sync all search input values. + * @param {string} value - Value to sync across inputs. + * @param {HTMLInputElement} target - Excluded input element from syncing. + */ keepInSync(value, target) { this.allSearchInputs.forEach((input) => { if (input !== target) { diff --git a/assets/media-gallery.js b/assets/media-gallery.js index c59cd4910ce..7db71c7b7b7 100644 --- a/assets/media-gallery.js +++ b/assets/media-gallery.js @@ -1,14 +1,27 @@ +/** + * @typedef {Object} MediaGalleryElements + * @property {HTMLElement} liveRegion - Live region element. + * @property {HTMLElement} viewer - Media viewer element. + * @property {HTMLElement} thumbnails - Media thumbnails element. + */ + if (!customElements.get('media-gallery')) { customElements.define( 'media-gallery', + /** + * Media Gallery custom element class. + * @extends HTMLElement + */ class MediaGallery extends HTMLElement { constructor() { super(); + /** @type {MediaGalleryElements} */ this.elements = { liveRegion: this.querySelector('[id^="GalleryStatus"]'), viewer: this.querySelector('[id^="GalleryViewer"]'), thumbnails: this.querySelector('[id^="GalleryThumbnails"]'), }; + /** @type {MediaQueryList} */ this.mql = window.matchMedia('(min-width: 750px)'); if (!this.elements.thumbnails) return; @@ -21,6 +34,10 @@ if (!customElements.get('media-gallery')) { if (this.dataset.desktopLayout.includes('thumbnail') && this.mql.matches) this.removeListSemantic(); } + /** + * Handle slide changed event. Set active thumbnail. + * @param {CustomEvent} event - Slide changed event. + */ onSlideChanged(event) { const thumbnail = this.elements.thumbnails.querySelector( `[data-target="${event.detail.currentElement.dataset.mediaId}"]` @@ -28,6 +45,11 @@ if (!customElements.get('media-gallery')) { this.setActiveThumbnail(thumbnail); } + /** + * Set active slide media. + * @param {string} mediaId - Media ID to set as active. + * @param {boolean} prepend - Whether to prepend active media. + */ setActiveMedia(mediaId, prepend) { const activeMedia = this.elements.viewer.querySelector(`[data-media-id="${mediaId}"]`) || @@ -70,6 +92,10 @@ if (!customElements.get('media-gallery')) { this.announceLiveRegion(activeMedia, activeThumbnail.dataset.mediaPosition); } + /** + * Set active thumbnail. Scroll to thumbnail if not visible. + * @param {HTMLElement} thumbnail - Thumbnail element to set as active. + */ setActiveThumbnail(thumbnail) { if (!this.elements.thumbnails || !thumbnail) return; @@ -82,7 +108,13 @@ if (!customElements.get('media-gallery')) { this.elements.thumbnails.slider.scrollTo({ left: thumbnail.offsetLeft }); } + /** + * Announce live region with active media position. Load active media. + * @param {HTMLElement} activeItem - Active media element. + * @param {number} position - Active media position. + */ announceLiveRegion(activeItem, position) { + /** @type {HTMLImageElement | undefined} */ const image = activeItem.querySelector('.product__modal-opener--image img'); if (!image) return; image.onload = () => { @@ -95,18 +127,30 @@ if (!customElements.get('media-gallery')) { image.src = image.src; } + /** + * Play active media. Pause all media before playing. Load deferred media. + * @param {HTMLElement} activeItem - Active media element. + */ playActiveMedia(activeItem) { window.pauseAllMedia(); + /** @type {DeferredMedia | undefined} */ const deferredMedia = activeItem.querySelector('.deferred-media'); if (deferredMedia) deferredMedia.loadContent(false); } + /** + * Prevent sticky header from revealing. Dispatch preventHeaderReveal event. + */ preventStickyHeader() { + /** @type {StickyHeader | undefined} */ this.stickyHeader = this.stickyHeader || document.querySelector('sticky-header'); if (!this.stickyHeader) return; this.stickyHeader.dispatchEvent(new Event('preventHeaderReveal')); } + /** + * Remove list semantic from thumbnails slider. Set roles to presentation. + */ removeListSemantic() { if (!this.elements.viewer.slider) return; this.elements.viewer.slider.setAttribute('role', 'presentation'); diff --git a/assets/password-modal.js b/assets/password-modal.js index 9df18f44809..67cf9c459d0 100644 --- a/assets/password-modal.js +++ b/assets/password-modal.js @@ -1,3 +1,7 @@ +/** + * Password modal custom element class. + * @extends DetailsModal + */ class PasswordModal extends DetailsModal { constructor() { super(); diff --git a/assets/pickup-availability.js b/assets/pickup-availability.js index 1b5ebd63579..957ceae38f4 100644 --- a/assets/pickup-availability.js +++ b/assets/pickup-availability.js @@ -1,17 +1,26 @@ if (!customElements.get('pickup-availability')) { customElements.define( 'pickup-availability', + /** + * Pickup Availability custom element class. + * @extends HTMLElement + */ class PickupAvailability extends HTMLElement { constructor() { super(); if (!this.hasAttribute('available')) return; + /** @type {HTMLElement} */ this.errorHtml = this.querySelector('template').content.firstElementChild.cloneNode(true); this.onClickRefreshList = this.onClickRefreshList.bind(this); this.fetchAvailability(this.dataset.variantId); } + /** + * Fetch availability data for variant. Render preview or error message. + * @param {string} variantId - Variant ID to fetch availability for. + */ fetchAvailability(variantId) { if (!variantId) return; @@ -19,6 +28,7 @@ if (!customElements.get('pickup-availability')) { if (!rootUrl.endsWith('/')) { rootUrl = rootUrl + '/'; } + /** Section API Url */ const variantSectionUrl = `${rootUrl}variants/${variantId}/?section_id=pickup-availability`; fetch(variantSectionUrl) @@ -36,10 +46,18 @@ if (!customElements.get('pickup-availability')) { }); } + /** + * Handle click event to refresh availability list. + */ onClickRefreshList() { this.fetchAvailability(this.dataset.variantId); } + /** + * Update availability for variant. + * TODO: Create variant object type. + * @param {JSON} variant - Variant object. + */ update(variant) { if (variant?.available) { this.fetchAvailability(variant.id); @@ -49,6 +67,9 @@ if (!customElements.get('pickup-availability')) { } } + /** + * Render error message. Enable refresh button. + */ renderError() { this.innerHTML = ''; this.appendChild(this.errorHtml); @@ -56,7 +77,12 @@ if (!customElements.get('pickup-availability')) { this.querySelector('button').addEventListener('click', this.onClickRefreshList); } + /** + * Render variant availability preview. + * @param {HTMLElement} sectionInnerHTML - Section HTML content. + */ renderPreview(sectionInnerHTML) { + /** @type {PickupAvailabilityDrawer | null} */ const drawer = document.querySelector('pickup-availability-drawer'); if (drawer) drawer.remove(); if (!sectionInnerHTML.querySelector('pickup-availability-preview')) { @@ -87,6 +113,10 @@ if (!customElements.get('pickup-availability')) { if (!customElements.get('pickup-availability-drawer')) { customElements.define( 'pickup-availability-drawer', + /** + * Pickup Availability Drawer custom element class. + * @extends HTMLElement + */ class PickupAvailabilityDrawer extends HTMLElement { constructor() { super(); @@ -102,6 +132,10 @@ if (!customElements.get('pickup-availability-drawer')) { }); } + /** + * Handle body click event to close drawer. + * @param {MouseEvent} evt - Mouse click event object. + */ handleBodyClick(evt) { const target = evt.target; if ( @@ -113,6 +147,9 @@ if (!customElements.get('pickup-availability-drawer')) { } } + /** + * Hide drawer. Remove open attribute, event listeners and focus trap. + */ hide() { this.removeAttribute('open'); document.body.removeEventListener('click', this.onBodyClick); @@ -120,6 +157,10 @@ if (!customElements.get('pickup-availability-drawer')) { removeTrapFocus(this.focusElement); } + /** + * Show drawer. Add open attribute, event listeners and focus trap. + * @param {HTMLElement} focusElement - Element to focus when drawer opens. + */ show(focusElement) { this.focusElement = focusElement; this.setAttribute('open', ''); diff --git a/assets/predictive-search.js b/assets/predictive-search.js index b30210be21c..af191fef42b 100644 --- a/assets/predictive-search.js +++ b/assets/predictive-search.js @@ -1,16 +1,27 @@ +/** + * Predictive search custom element class. + * @extends SearchForm + */ class PredictiveSearch extends SearchForm { constructor() { super(); + /** @type {Object} */ this.cachedResults = {}; + /** @type {HTMLElement} */ this.predictiveSearchResults = this.querySelector('[data-predictive-search]'); + /** @type {NodeListOf} */ this.allPredictiveSearchInstances = document.querySelectorAll('predictive-search'); + /** @type {boolean} */ this.isOpen = false; + /** @type {AbortController} */ this.abortController = new AbortController(); + /** @type {string} */ this.searchTerm = ''; this.setupEventListeners(); } + /** Setup and bind event listeners. */ setupEventListeners() { this.input.form.addEventListener('submit', this.onFormSubmit.bind(this)); @@ -20,10 +31,15 @@ class PredictiveSearch extends SearchForm { this.addEventListener('keydown', this.onKeydown.bind(this)); } + /** + * Get the search query from the input. + * @returns {string} Search query. + */ getQuery() { return this.input.value.trim(); } + /** Handle input change event. Update search term and get search results. */ onChange() { super.onChange(); const newSearchTerm = this.getQuery(); @@ -46,10 +62,18 @@ class PredictiveSearch extends SearchForm { this.getSearchResults(this.searchTerm); } + /** + * Handle form submit event. Prevent form submission if no search results available. + * @param {Event} event - Submit event object. + */ onFormSubmit(event) { if (!this.getQuery().length || this.querySelector('[aria-selected="true"] a')) event.preventDefault(); } + /** + * handle form reset event. Reset search term and abort search query. + * @param {Event} event - Reset event object. + */ onFormReset(event) { super.onFormReset(event); if (super.shouldResetForm()) { @@ -60,6 +84,7 @@ class PredictiveSearch extends SearchForm { } } + /** Handle focus event. Open search dropdown if results available. */ onFocus() { const currentSearchTerm = this.getQuery(); @@ -75,12 +100,17 @@ class PredictiveSearch extends SearchForm { } } + /** Handle focus out event. Close search dropdown if focus is outside. */ onFocusOut() { setTimeout(() => { if (!this.contains(document.activeElement)) this.close(); }); } + /** + * Handle keyup event. Up/down arrow keys to switch options, enter to select. + * @param {KeyboardEvent} event - Keyup event object. + */ onKeyup(event) { if (!this.getQuery().length) this.close(true); event.preventDefault(); @@ -98,15 +128,24 @@ class PredictiveSearch extends SearchForm { } } + /** + * Handle keydown event. Prevent cursor from moving in input when using up/down arrow keys. + * @param {KeyboardEvent} event - Keydown event object. + */ onKeydown(event) { - // Prevent the cursor from moving in the input when using the up and down arrow keys if (event.code === 'ArrowUp' || event.code === 'ArrowDown') { event.preventDefault(); } } + /** + * Update 'Searched for' button text with new search term. + * @param {string} previousTerm - Previous search term. + * @param {string} newTerm - New search term. + */ updateSearchForTerm(previousTerm, newTerm) { const searchForTextElement = this.querySelector('[data-predictive-search-search-for-text]'); + /** @type {string | undefined} */ const currentButtonText = searchForTextElement?.innerText; if (currentButtonText) { if (currentButtonText.match(new RegExp(previousTerm, 'g')).length > 1) { @@ -118,6 +157,10 @@ class PredictiveSearch extends SearchForm { } } + /** + * Switch selected search dropdown option. + * @param {'up' | 'down'} direction - Direction to switch options. + */ switchOption(direction) { if (!this.getAttribute('open')) return; @@ -143,6 +186,7 @@ class PredictiveSearch extends SearchForm { i++; } + /** @type {HTMLElement} */ this.statusElement.textContent = ''; if (!moveUp && selectedElement) { @@ -161,12 +205,17 @@ class PredictiveSearch extends SearchForm { this.input.setAttribute('aria-activedescendant', activeElement.id); } + /** Select search dropdown option. Click selected option. */ selectOption() { + /** @type {HTMLButtonElement | null} */ const selectedOption = this.querySelector('[aria-selected="true"] a, button[aria-selected="true"]'); - if (selectedOption) selectedOption.click(); } + /** + * Get search results from predictive search API or cache. + * @param {string} searchTerm - Search term. + */ getSearchResults(searchTerm) { const queryKey = searchTerm.replace(' ', '-').toLowerCase(); this.setLiveRegionLoadingState(); @@ -198,7 +247,7 @@ class PredictiveSearch extends SearchForm { }); this.renderSearchResults(resultsMarkup); }) - .catch((error) => { + .catch((/** @type {Error} */ error) => { if (error?.code === 20) { // Code 20 means the call was aborted return; @@ -208,14 +257,21 @@ class PredictiveSearch extends SearchForm { }); } + /** Set loading state for live region. */ setLiveRegionLoadingState() { + /** @type {HTMLSpanElement} */ this.statusElement = this.statusElement || this.querySelector('.predictive-search-status'); + /** @type {string} */ this.loadingText = this.loadingText || this.getAttribute('data-loading-text'); this.setLiveRegionText(this.loadingText); this.setAttribute('loading', true); } + /** + * Set text for live region. + * @param {string} statusText - Text to set in live region. + */ setLiveRegionText(statusText) { this.statusElement.setAttribute('aria-hidden', 'false'); this.statusElement.textContent = statusText; @@ -225,6 +281,10 @@ class PredictiveSearch extends SearchForm { }, 1000); } + /** + * Render search results in results dropdown. + * @param {string} resultsMarkup - Search results html markup. + */ renderSearchResults(resultsMarkup) { this.predictiveSearchResults.innerHTML = resultsMarkup; this.setAttribute('results', true); @@ -233,17 +293,23 @@ class PredictiveSearch extends SearchForm { this.open(); } + /** Remove loading state from live region and set results count. */ setLiveRegionResults() { this.removeAttribute('loading'); this.setLiveRegionText(this.querySelector('[data-predictive-search-live-region-count-value]').textContent); } + /** + * Get results dropdown max height. + * @returns {number} Max height (px) for results dropdown. + */ getResultsMaxHeight() { this.resultsMaxHeight = window.innerHeight - document.querySelector('.section-header')?.getBoundingClientRect().bottom; return this.resultsMaxHeight; } + /** Open search. Set max height and open attributes. */ open() { this.predictiveSearchResults.style.maxHeight = this.resultsMaxHeight || `${this.getResultsMaxHeight()}px`; this.setAttribute('open', true); @@ -251,11 +317,19 @@ class PredictiveSearch extends SearchForm { this.isOpen = true; } + /** + * Close search. + * @param {boolean} [clearSearchTerm=false] - Clear search term from input. + */ close(clearSearchTerm = false) { this.closeResults(clearSearchTerm); this.isOpen = false; } + /** + * Close search results dropdown. + * @param {boolean} [clearSearchTerm=false] - Clear search term from input. + */ closeResults(clearSearchTerm = false) { if (clearSearchTerm) { this.input.value = ''; diff --git a/assets/price-per-item.js b/assets/price-per-item.js index fdf37eb9700..e5ab319c23a 100644 --- a/assets/price-per-item.js +++ b/assets/price-per-item.js @@ -1,10 +1,16 @@ if (!customElements.get('price-per-item')) { customElements.define( 'price-per-item', + /** + * Price Per Item custom element class. + * @extends HTMLElement + */ class PricePerItem extends HTMLElement { constructor() { super(); + /** @type {string} */ this.variantId = this.dataset.variantId; + /** @type {HTMLInputElement | null} */ this.input = document.getElementById(`Quantity-${this.dataset.sectionId || this.dataset.variantId}`); if (this.input) { this.input.addEventListener('change', this.onInputChange.bind(this)); @@ -13,7 +19,9 @@ if (!customElements.get('price-per-item')) { this.getVolumePricingArray(); } + /** @type {Function | undefined} */ updatePricePerItemUnsubscriber = undefined; + /** @type {Function | undefined} */ variantIdChangedUnsubscriber = undefined; connectedCallback() { @@ -55,10 +63,15 @@ if (!customElements.get('price-per-item')) { } } + /** Handle input change event. Update price per item. */ onInputChange() { this.updatePricePerItem(); } + /** + * Update price per item based on quantity in cart. + * @param {number | undefined} updatedCartQuantity - Updated quantity of variant in cart. + */ updatePricePerItem(updatedCartQuantity) { if (this.input) { this.enteredQty = parseInt(this.input.value); @@ -67,6 +80,7 @@ if (!customElements.get('price-per-item')) { // updatedCartQuantity is undefined when qty is updated on product page. We need to sum entered qty and current qty in cart. // updatedCartQuantity is not undefined when qty is updated in cart. We need to sum qty in cart and min qty for product. + /** @type {number} */ this.currentQtyForVolumePricing = updatedCartQuantity === undefined ? this.getCartQuantity(updatedCartQuantity) + this.enteredQty : this.getCartQuantity(updatedCartQuantity) + parseInt(this.step); if (this.classList.contains('variant-item__price-per-item')) { @@ -81,17 +95,27 @@ if (!customElements.get('price-per-item')) { } } + /** + * Get quantity of variant in cart. + * @param {number | undefined} updatedCartQuantity - Updated quantity of variant in cart. + * @returns {number} - Quantity of variant in cart. + */ getCartQuantity(updatedCartQuantity) { return (updatedCartQuantity || updatedCartQuantity === 0) ? updatedCartQuantity : parseInt(this.input.dataset.cartQuantity); } + /** + * Get volume pricing array from product page. + */ getVolumePricingArray() { const volumePricing = document.getElementById(`Volume-${this.dataset.sectionId || this.dataset.variantId}`); + /** @type {Array<{qty: number, price: string}>} */ this.qtyPricePairs = []; if (volumePricing) { volumePricing.querySelectorAll('li').forEach(li => { const qty = parseInt(li.querySelector('span:first-child').textContent); + /** @type {string} */ const price = (li.querySelector('span:not(:first-child):last-child').dataset.text); this.qtyPricePairs.push([qty, price]); }); diff --git a/assets/product-form.js b/assets/product-form.js index 59c19c9ec36..6bf355f0850 100644 --- a/assets/product-form.js +++ b/assets/product-form.js @@ -1,22 +1,36 @@ if (!customElements.get('product-form')) { customElements.define( 'product-form', + /** + * Product Form custom element class. + * @extends HTMLElement + */ class ProductForm extends HTMLElement { constructor() { super(); + /** @type {HTMLFormElement} */ this.form = this.querySelector('form'); this.variantIdInput.disabled = false; this.form.addEventListener('submit', this.onSubmitHandler.bind(this)); + /** @type {CartNotification | CartDrawer} */ this.cart = document.querySelector('cart-notification') || document.querySelector('cart-drawer'); + /** @type {HTMLButtonElement} */ this.submitButton = this.querySelector('[type="submit"]'); + /** @type {string} */ this.submitButtonText = this.submitButton.querySelector('span'); if (document.querySelector('cart-drawer')) this.submitButton.setAttribute('aria-haspopup', 'dialog'); + /** @type {boolean} */ this.hideErrors = this.dataset.hideErrors === 'true'; } + /** + * Handle form submission. + * Prevents default form submission and sends form data via fetch. + * @param {SubmitEvent} evt - Submit event object. + */ onSubmitHandler(evt) { evt.preventDefault(); if (this.submitButton.getAttribute('aria-disabled') === 'true') return; @@ -44,7 +58,7 @@ if (!customElements.get('product-form')) { fetch(`${routes.cart_add_url}`, config) .then((response) => response.json()) - .then((response) => { + .then((/** @type {JSON} */ response) => { if (response.status) { publish(PUB_SUB_EVENTS.cartError, { source: 'product-form', @@ -89,7 +103,7 @@ if (!customElements.get('product-form')) { this.cart.renderContents(response); } }) - .catch((e) => { + .catch((/** @type {Error} */ e) => { console.error(e); }) .finally(() => { @@ -100,12 +114,18 @@ if (!customElements.get('product-form')) { }); } + /** + * Handle error message display. + * @param {string | boolean} [errorMessage=false] - Error message to display. If false, hides error message. + */ handleErrorMessage(errorMessage = false) { if (this.hideErrors) return; + /** @type {HTMLElement | null} */ this.errorMessageWrapper = this.errorMessageWrapper || this.querySelector('.product-form__error-message-wrapper'); if (!this.errorMessageWrapper) return; + /** @type {HTMLElement | null} */ this.errorMessage = this.errorMessage || this.errorMessageWrapper.querySelector('.product-form__error-message'); this.errorMessageWrapper.toggleAttribute('hidden', !errorMessage); @@ -115,6 +135,11 @@ if (!customElements.get('product-form')) { } } + /** + * Toggle submit button state. + * @param {boolean} [disable=true] - Whether to disable submit button. + * @param {string} text - Text to display in submit button. + */ toggleSubmitButton(disable = true, text) { if (disable) { this.submitButton.setAttribute('disabled', 'disabled'); @@ -125,6 +150,10 @@ if (!customElements.get('product-form')) { } } + /** + * Get variant ID input element. + * @returns {HTMLInputElement} + */ get variantIdInput() { return this.form.querySelector('[name=id]'); } diff --git a/assets/product-info.js b/assets/product-info.js index 5c362e35c12..afb5854d2b6 100644 --- a/assets/product-info.js +++ b/assets/product-info.js @@ -2,13 +2,21 @@ if (!customElements.get('product-info')) { customElements.define( 'product-info', class ProductInfo extends HTMLElement { + /** @type {HTMLInputElement | undefined} */ quantityInput = undefined; + /** @type {HTMLFormElement | undefined} */ quantityForm = undefined; + /** @type {Function | undefined} */ onVariantChangeUnsubscriber = undefined; + /** @type {Function | undefined} */ cartUpdateUnsubscriber = undefined; + /** @type {AbortController | undefined} */ abortController = undefined; + /** @type {string | null} */ pendingRequestUrl = null; + /** @type {Array} */ preProcessHtmlCallbacks = []; + /** @type {Array} */ postProcessHtmlCallbacks = []; constructor() { @@ -29,17 +37,22 @@ if (!customElements.get('product-info')) { this.dispatchEvent(new CustomEvent('product-info:loaded', { bubbles: true })); } + /** + * Add callback to be executed before HTML is processed. + * @param {Function} callback - Callback function to be executed. + */ addPreProcessCallback(callback) { this.preProcessHtmlCallbacks.push(callback); } + /** Set quantity input boundaries. */ initQuantityHandlers() { if (!this.quantityInput) return; this.quantityForm = this.querySelector('.product-form__quantity'); if (!this.quantityForm) return; - this.setQuantityBoundries(); + this.setQuantityBoundaries(); if (!this.dataset.originalSection) { this.cartUpdateUnsubscriber = subscribe(PUB_SUB_EVENTS.cartUpdate, this.fetchQuantityRules.bind(this)); } @@ -50,21 +63,30 @@ if (!customElements.get('product-info')) { this.cartUpdateUnsubscriber?.(); } + /** + * Initialize product swap utility. + * Cancel scroll-trigger on product swap and reinitialize Shopify Payment Button and Shopify XR. + */ initializeProductSwapUtility() { this.preProcessHtmlCallbacks.push((html) => html.querySelectorAll('.scroll-trigger').forEach((element) => element.classList.add('scroll-trigger--cancel')) ); - this.postProcessHtmlCallbacks.push((newNode) => { + this.postProcessHtmlCallbacks.push((_newNode) => { window?.Shopify?.PaymentButton?.init(); window?.ProductModel?.loadShopifyXR(); }); } + /** + * Handle option value change event. + * @param {CustomEvent} event - Option value change event. + */ handleOptionValueChange({ data: { event, target, selectedOptionValues } }) { if (!this.contains(event.target)) return; this.resetProductFormState(); + /** @type {string} */ const productUrl = target.dataset.productUrl || this.pendingRequestUrl || this.dataset.url; this.pendingRequestUrl = productUrl; const shouldSwapProduct = this.dataset.url !== productUrl; @@ -79,12 +101,18 @@ if (!customElements.get('product-info')) { }); } + /** Reset product form state. */ resetProductFormState() { const productForm = this.productForm; productForm?.toggleSubmitButton(true); productForm?.handleErrorMessage(); } + /** + * Handle product swap. + * @param {string} productUrl - Product URL. + * @param {boolean} updateFullPage - Whether to update full page. + */ handleSwapProduct(productUrl, updateFullPage) { return (html) => { this.productModal?.remove(); @@ -113,6 +141,14 @@ if (!customElements.get('product-info')) { }; } + + /** + * Renders product information. + * @param {Object} options - Options for rendering product information. + * @param {string} options.requestUrl - URL to fetch product information from. + * @param {string} options.targetId - ID of target element to render product information into. + * @param {Function} options.callback - Callback function to be executed after rendering product information. + */ renderProductInfo({ requestUrl, targetId, callback }) { this.abortController?.abort(); this.abortController = new AbortController(); @@ -128,7 +164,7 @@ if (!customElements.get('product-info')) { // set focus to last clicked option value document.querySelector(`#${targetId}`)?.focus(); }) - .catch((error) => { + .catch((/** @type {Error} */error) => { if (error.name === 'AbortError') { console.log('Fetch aborted by user'); } else { @@ -137,12 +173,25 @@ if (!customElements.get('product-info')) { }); } + /** + * Get selected variant JSON from product info node. + * @param {HTMLElement} productInfoNode - Product info node. + * @returns {Object | null} Selected variant JSON. + */ getSelectedVariant(productInfoNode) { const selectedVariant = productInfoNode.querySelector('variant-selects [data-selected-variant]')?.innerHTML; return !!selectedVariant ? JSON.parse(selectedVariant) : null; } + /** + * Build request URL with params for fetching product information via section API. + * @param {string} url - Product URL. + * @param {Array} optionValues - Selected option values. + * @param {boolean} [shouldFetchFullPage=false] - Whether to fetch full page. + * @returns {string} New request URL with params. + */ buildRequestUrlWithParams(url, optionValues, shouldFetchFullPage = false) { + /** @type {Array} */ const params = []; !shouldFetchFullPage && params.push(`section_id=${this.sectionId}`); @@ -154,13 +203,24 @@ if (!customElements.get('product-info')) { return `${url}?${params.join('&')}`; } + /** + * Update selected option values in variant selectors. + * @param {HTMLElement} html - HTML element. + */ updateOptionValues(html) { + /** @type {VariantSelects | null} */ const variantSelects = html.querySelector('variant-selects'); if (variantSelects) { HTMLUpdateUtility.viewTransition(this.variantSelectors, variantSelects, this.preProcessHtmlCallbacks); } } + + /** + * Handles product information update. + * @param {string} productUrl - Product URL. + * @returns {(html: HTMLElement)} - Callback function that takes an HTML parameter and performs various updates based on content. + */ handleUpdateProductInfo(productUrl) { return (html) => { const variant = this.getSelectedVariant(html); @@ -177,8 +237,15 @@ if (!customElements.get('product-info')) { this.updateMedia(html, variant?.featured_media?.id); - const updateSourceFromDestination = (id, shouldHide = (source) => false) => { + /** + * Update source from destination. + * @param {string} id - ID of element to update. + * @param {function(): boolean} [shouldHide] - Function to determine if element should be hidden. + */ + const updateSourceFromDestination = (id, shouldHide = (_source) => false) => { + /** @type {HTMLElement | null} */ const source = html.getElementById(`${id}-${this.sectionId}`); + /** @type {HTMLElement | null} */ const destination = this.querySelector(`#${id}-${this.dataset.section}`); if (source && destination) { destination.innerHTML = source.innerHTML; @@ -211,6 +278,10 @@ if (!customElements.get('product-info')) { }; } + /** + * Update variant inputs. + * @param {string} variantId - Variant ID. + */ updateVariantInputs(variantId) { this.querySelectorAll( `#product-form-${this.dataset.section}, #product-form-installment-${this.dataset.section}` @@ -221,6 +292,11 @@ if (!customElements.get('product-info')) { }); } + /** + * Update share button URL. + * @param {string} url - Product URL. + * @param {string} variantId - Variant ID. + */ updateURL(url, variantId) { this.querySelector('share-button')?.updateUrl( `${window.shopUrl}${url}${variantId ? `?variant=${variantId}` : ''}` @@ -230,6 +306,7 @@ if (!customElements.get('product-info')) { window.history.replaceState({}, '', `${url}${variantId ? `?variant=${variantId}` : ''}`); } + /** Set product as unavailable. */ setUnavailable() { this.productForm?.toggleSubmitButton(true, window.variantStrings.unavailable); @@ -239,16 +316,32 @@ if (!customElements.get('product-info')) { document.querySelectorAll(selectors).forEach(({ classList }) => classList.add('hidden')); } + /** + * Update product media. + * @param {HTMLElement} html - HTML element. + * @param {string} variantFeaturedMediaId - Variant featured media ID. + */ updateMedia(html, variantFeaturedMediaId) { if (!variantFeaturedMediaId) return; + /** + * @typedef {Object} MediaItem + * @property {string} dataset.mediaId - Media ID. + * + * @typedef {Object} MediaData + * @property {MediaItem} item - Media item. + * @property {number} index - Media index. + */ + const mediaGallerySource = this.querySelector('media-gallery ul'); const mediaGalleryDestination = html.querySelector(`media-gallery ul`); const refreshSourceData = () => { if (this.hasAttribute('data-zoom-on-hover')) enableZoomOnHover(2); const mediaGallerySourceItems = Array.from(mediaGallerySource.querySelectorAll('li[data-media-id]')); + /** @type {Set} */ const sourceSet = new Set(mediaGallerySourceItems.map((item) => item.dataset.mediaId)); + /** @type {Map} */ const sourceMap = new Map( mediaGallerySourceItems.map((item, index) => [item.dataset.mediaId, { item, index }]) ); @@ -260,6 +353,7 @@ if (!customElements.get('product-info')) { const mediaGalleryDestinationItems = Array.from( mediaGalleryDestination.querySelectorAll('li[data-media-id]') ); + /** @type {Set} */ const destinationSet = new Set(mediaGalleryDestinationItems.map(({ dataset }) => dataset.mediaId)); let shouldRefresh = false; @@ -284,6 +378,7 @@ if (!customElements.get('product-info')) { // if media galleries don't match, sort to match new data order mediaGalleryDestinationItems.forEach((destinationItem, destinationIndex) => { + /** @type {MediaData} */ const sourceData = sourceMap.get(destinationItem.dataset.mediaId); if (sourceData && sourceData.index !== destinationIndex) { @@ -310,7 +405,8 @@ if (!customElements.get('product-info')) { if (modalContent && newModalContent) modalContent.innerHTML = newModalContent.innerHTML; } - setQuantityBoundries() { + /** Set quantity input boundaries. */ + setQuantityBoundaries() { const data = { cartQuantity: this.quantityInput.dataset.cartQuantity ? parseInt(this.quantityInput.dataset.cartQuantity) : 0, min: this.quantityInput.dataset.min ? parseInt(this.quantityInput.dataset.min) : 1, @@ -335,7 +431,12 @@ if (!customElements.get('product-info')) { publish(PUB_SUB_EVENTS.quantityUpdate, undefined); } + /** + * Fetch quantity rules. Trigger quantity rules update. + * @returns {Promise} - Quantity rules promise. + */ fetchQuantityRules() { + /** @type {string | null} */ const currentVariantId = this.productForm?.variantIdInput?.value; if (!currentVariantId) return; @@ -350,9 +451,14 @@ if (!customElements.get('product-info')) { .finally(() => this.querySelector('.quantity__rules-cart .loading__spinner').classList.add('hidden')); } + /** + * Update quantity rules in product form. + * @param {string} sectionId - Section ID. + * @param {Document} html - HTML document. + */ updateQuantityRules(sectionId, html) { if (!this.quantityInput) return; - this.setQuantityBoundries(); + this.setQuantityBoundaries(); const quantityFormUpdated = html.getElementById(`Quantity-Form-${sectionId}`); const selectors = ['.quantity__input', '.quantity__rules', '.quantity__label']; diff --git a/assets/pubsub.js b/assets/pubsub.js index ad27be093c6..2631fdfcf26 100644 --- a/assets/pubsub.js +++ b/assets/pubsub.js @@ -1,5 +1,15 @@ +/** + * List of subscribers for each event. + * @type {Object>} + */ let subscribers = {}; +/** + * Subscribe to an event. + * @param {string} eventName - Name of event to subscribe to. + * @param {Function} callback - Callback function to execute on event. + * @returns {Function} Unsubscribe function. + */ function subscribe(eventName, callback) { if (subscribers[eventName] === undefined) { subscribers[eventName] = []; @@ -7,6 +17,7 @@ function subscribe(eventName, callback) { subscribers[eventName] = [...subscribers[eventName], callback]; + /** Unsubscribe from an event. Remove callback from event name in subscribers array. */ return function unsubscribe() { subscribers[eventName] = subscribers[eventName].filter((cb) => { return cb !== callback; @@ -14,6 +25,11 @@ function subscribe(eventName, callback) { }; } +/** + * Publish an event. + * @param {string} eventName - Name of event to publish. + * @param {any} data - Data to pass to subscribers. + */ function publish(eventName, data) { if (subscribers[eventName]) { subscribers[eventName].forEach((callback) => { diff --git a/assets/quick-add.js b/assets/quick-add.js index 5125a974c2f..f28a0201b43 100644 --- a/assets/quick-add.js +++ b/assets/quick-add.js @@ -1,9 +1,14 @@ if (!customElements.get('quick-add-modal')) { customElements.define( 'quick-add-modal', + /** + * Quick Add Modal custom element class. + * @extends ModalDialog + */ class QuickAddModal extends ModalDialog { constructor() { super(); + /** @type {HTMLElement | null} */ this.modalContent = this.querySelector('[id^="QuickAddInfo-"]'); this.addEventListener('product-info:loaded', ({ target }) => { @@ -11,6 +16,10 @@ if (!customElements.get('quick-add-modal')) { }); } + /** + * Hide quick add modal. + * @param {boolean} [preventFocus=false] - Whether to prevent focusing on opener after close. + */ hide(preventFocus = false) { const cartNotification = document.querySelector('cart-notification') || document.querySelector('cart-drawer'); if (cartNotification) cartNotification.setActiveElement(this.openedBy); @@ -20,6 +29,10 @@ if (!customElements.get('quick-add-modal')) { super.hide(); } + /** + * Show quick add modal. + * @param {HTMLElement} opener - Element that triggered opening of modal. + */ show(opener) { opener.setAttribute('aria-disabled', true); opener.classList.add('loading'); @@ -48,6 +61,10 @@ if (!customElements.get('quick-add-modal')) { }); } + /** + * Preprocess product info HTML. + * @param {ProductInfo} productElement - Product info element. + */ preprocessHTML(productElement) { productElement.classList.forEach((classApplied) => { if (classApplied.startsWith('color-') || classApplied === 'gradient') @@ -60,21 +77,36 @@ if (!customElements.get('quick-add-modal')) { this.preventVariantURLSwitching(productElement); } + /** + * Prevent switching variant URLs. Set data-update-url to false. + * @param {ProductInfo} productElement - Product info element. + */ preventVariantURLSwitching(productElement) { productElement.setAttribute('data-update-url', 'false'); } + /** + * Remove unnecessary DOM elements. + * @param {ProductInfo} productElement - Product info element. + */ removeDOMElements(productElement) { const pickupAvailability = productElement.querySelector('pickup-availability'); if (pickupAvailability) pickupAvailability.remove(); + /** @type {ModalDialog | null} */ const productModal = productElement.querySelector('product-modal'); if (productModal) productModal.remove(); + /** @type {NodeListOf} */ const modalDialog = productElement.querySelectorAll('modal-dialog'); if (modalDialog) modalDialog.forEach((modal) => modal.remove()); } + /** + * Prevent duplicated IDs across quick add modal and page. + * Prefix modal IDs with 'quickadd-'. + * @param {ProductInfo} productElement - Product info element. + */ preventDuplicatedIDs(productElement) { const sectionId = productElement.dataset.section; @@ -90,6 +122,10 @@ if (!customElements.get('quick-add-modal')) { productElement.dataset.originalSection = sectionId; } + /** + * Remove gallery list semantic roles. + * @param {ProductInfo} productElement - Product info element + */ removeGalleryListSemantic(productElement) { const galleryList = productElement.querySelector('[id^="Slider-Gallery"]'); if (!galleryList) return; @@ -98,6 +134,10 @@ if (!customElements.get('quick-add-modal')) { galleryList.querySelectorAll('[id^="Slide-"]').forEach((li) => li.setAttribute('role', 'presentation')); } + /** + * Set product media image sizes based on product page width. + * @param {ProductInfo} productElement - Product info element + */ updateImageSizes(productElement) { const product = productElement.querySelector('.product'); const desktopColumns = product?.classList.contains('product--columns'); diff --git a/assets/search-form.js b/assets/search-form.js index f95e8b79cd7..a32e8696797 100644 --- a/assets/search-form.js +++ b/assets/search-form.js @@ -1,7 +1,13 @@ +/** + * Search form custom element class. + * @extends HTMLElement + */ class SearchForm extends HTMLElement { constructor() { super(); + /** @type {HTMLInputElement} */ this.input = this.querySelector('input[type="search"]'); + /** @type {HTMLButtonElement} */ this.resetButton = this.querySelector('button[type="reset"]'); if (this.input) { @@ -15,6 +21,7 @@ class SearchForm extends HTMLElement { } } + /** Toggle input reset button visibility. Show when input has value. */ toggleResetButton() { const resetIsHidden = this.resetButton.classList.contains('hidden'); if (this.input.value.length > 0 && resetIsHidden) { @@ -24,14 +31,20 @@ class SearchForm extends HTMLElement { } } + /** Handle input change event. Toggle reset button visibility. */ onChange() { this.toggleResetButton(); } + /** Check if form should be reset. */ shouldResetForm() { return !document.querySelector('[aria-selected="true"] a'); } + /** + * Handle form reset event. Reset input value and focus. + * @param {Event} event - Reset event object. + */ onFormReset(event) { // Prevent default so the form reset doesn't set the value gotten from the url on page load event.preventDefault(); diff --git a/assets/theme-editor.js b/assets/theme-editor.js index 557c908a3b3..7e845831a91 100644 --- a/assets/theme-editor.js +++ b/assets/theme-editor.js @@ -1,13 +1,17 @@ +/** Hide Product Modal */ function hideProductModal() { + /** @type {NodeListOf} */ const productModal = document.querySelectorAll('product-modal[open]'); productModal && productModal.forEach((modal) => modal.hide()); } document.addEventListener('shopify:block:select', function (event) { hideProductModal(); + /** @type {boolean} */ const blockSelectedIsSlide = event.target.classList.contains('slideshow__slide'); if (!blockSelectedIsSlide) return; + /** @type {SlideshowComponent | null} */ const parentSlideshowComponent = event.target.closest('slideshow-component'); parentSlideshowComponent.pause(); @@ -19,14 +23,17 @@ document.addEventListener('shopify:block:select', function (event) { }); document.addEventListener('shopify:block:deselect', function (event) { + /** @type {boolean} */ const blockDeselectedIsSlide = event.target.classList.contains('slideshow__slide'); if (!blockDeselectedIsSlide) return; + /** @type {SlideshowComponent | null} */ const parentSlideshowComponent = event.target.closest('slideshow-component'); if (parentSlideshowComponent.autoplayButtonIsSetToPlay) parentSlideshowComponent.play(); }); document.addEventListener('shopify:section:load', () => { hideProductModal(); + /** @type {HTMLScriptElement | null} */ const zoomOnHoverScript = document.querySelector('[id^=EnableZoomOnHover]'); if (!zoomOnHoverScript) return; if (zoomOnHoverScript) {