{"version":3,"file":"collection-sale-page-section.min.js","mappings":"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","sources":["webpack://lange/./sections/collection-sale-page/collection-viewed.ts","webpack://lange/webpack/bootstrap","webpack://lange/./sections/collection-sale-page/sale-page-filters/index.ts"],"sourcesContent":["type CollectionViewedEvent = GlobalEventHandlersEventMap['collection-viewed'];\n\nconst event = new CustomEvent<CollectionViewedEvent['detail']>('collection-viewed', {\n  detail: {\n    all_products_count: collection_sale_page_section.collection.all_products_count,\n    handle: collection_sale_page_section.collection.handle,\n    id: collection_sale_page_section.collection.id,\n    products_count: collection_sale_page_section.collection.products_count,\n    title: collection_sale_page_section.collection.title,\n    url: collection_sale_page_section.collection.url\n  }\n});\n\ndispatchEvent(event);\n","// The module cache\nlet __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tlet cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tlet module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","import type { CustomElement } from '@integrabeauty/custom-elements';\nimport html from './index.html';\nimport styles from './index.scss';\n\n/**\n * The query parameter carrying the picked filter's label, so a filtered page can be linked,\n * bookmarked, and stepped back out of. Shopify's own storefront filters all sit under \"filter.\", so\n * this name cannot collide with one.\n */\nconst CATEGORY_PARAM = 'category';\n\n/**\n * The label of the All button, which shows every card. It is what the page starts on, so it is the\n * absence of the query parameter rather than a value it takes. Liquid renders no filter with a\n * blank label, so no filter can collide with it.\n */\nconst ALL_LABEL = '';\n\ntype ProductCard = HTMLElementTagNameMap['product-card'];\ntype SalePageFilter = HTMLElementTagNameMap['sale-page-filter'];\n\n/**\n * The row of filter buttons on the sale page. The filters come from the collection's sale page\n * filters metaobject, which Liquid renders as `<sale-page-filter>` children. Each one names the\n * values to match and whether to match them against a card's data-type, its product's tags, or\n * both.\n *\n * The product grid sits outside this element, and data-products-container-id names the element\n * holding the cards it shows and hides.\n *\n * The picked filter's label also goes in the url, so a filtered page survives a reload and the back\n * button steps through the picks.\n */\nclass SalePageFilters extends HTMLElement implements CustomElement {\n  /**\n   * The label of the filter currently showing. It is not reflected to an attribute, since nothing\n   * outside the element reads it.\n   */\n  private selectedLabel = ALL_LABEL;\n\n  private onClickBound = this.onClick.bind(this);\n  private onPopStateBound = this.onPopState.bind(this);\n\n  public connectedCallback() {\n    // The bundle is deferred, so the product grid after this element is fully parsed by the time\n    // the element upgrades and the cards are there to match against.\n    this.initializeShadowRoot();\n\n    const labelFromUrl = readLabelFromUrl();\n    this.selectLabel(labelFromUrl);\n\n    const filters = this.shadowRoot.querySelector<HTMLElement>('.filters');\n    filters.addEventListener('click', this.onClickBound);\n\n    addEventListener('popstate', this.onPopStateBound);\n  }\n\n  public disconnectedCallback() {\n    const filters = this.shadowRoot.querySelector<HTMLElement>('.filters');\n    filters.removeEventListener('click', this.onClickBound);\n\n    removeEventListener('popstate', this.onPopStateBound);\n  }\n\n  private initializeShadowRoot() {\n    if (!this.shadowRoot) {\n      this.attachShadow({ mode: 'open' });\n      this.shadowRoot.innerHTML = `<style>${styles}</style>${html}`;\n      this.appendFilterButtons();\n    }\n  }\n\n  /**\n   * Adds a button after All for every filter that at least one card matches. A filter matching no\n   * card would only ever show an empty grid, so it gets no button. When no filter gets one, All on\n   * its own would do nothing, so the whole row is hidden.\n   */\n  private appendFilterButtons() {\n    const container = document.getElementById(this.dataset.productsContainerId);\n    const cards = container.querySelectorAll('product-card');\n    const fragment = document.createDocumentFragment();\n\n    const seenLabels = new Set<string>();\n    const filters = this.querySelectorAll('sale-page-filter');\n    for (const filter of filters) {\n      // The url and the buttons know a filter only by its label, so a second filter with the same\n      // label could never be picked on its own. Only the first one counts, here and in renderCards.\n      if (seenLabels.has(filter.dataset.label)) {\n        continue;\n      }\n\n      seenLabels.add(filter.dataset.label);\n\n      let hasMatchingCard = false;\n      for (const card of cards) {\n        if (matchesFilter(card, filter)) {\n          hasMatchingCard = true;\n          break;\n        }\n      }\n\n      if (hasMatchingCard) {\n        // The carousel puts a role and a label like \"2 of 9\" on each slide, which would replace the\n        // button's own, so the button sits inside a slide rather than being one.\n        const slide = document.createElement('div');\n        slide.dataset.carouselSlide = '';\n\n        const button = document.createElement('button');\n        button.type = 'button';\n        button.dataset.label = filter.dataset.label;\n        button.setAttribute('aria-pressed', 'false');\n        button.textContent = filter.dataset.label;\n        slide.appendChild(button);\n\n        fragment.appendChild(slide);\n      }\n    }\n\n    this.hidden = fragment.childElementCount === 0;\n\n    const filtersContainer = this.shadowRoot.querySelector('.filters');\n    filtersContainer.appendChild(fragment);\n  }\n\n  private onClick(event: MouseEvent) {\n    if (!(event.target instanceof HTMLButtonElement)) {\n      return;\n    }\n\n    // Re-picking the filter already showing changes nothing, so it does not get a history entry\n    // either.\n    if (event.target.dataset.label === this.selectedLabel) {\n      return;\n    }\n\n    this.selectLabel(event.target.dataset.label);\n    writeLabelToUrl(this.selectedLabel);\n  }\n\n  /**\n   * Puts the page back to whatever filter the url now names, for the back and forward buttons. The\n   * url already moved on its own here, so it is only read.\n   */\n  private onPopState(_event: PopStateEvent) {\n    this.selectLabel(readLabelFromUrl());\n  }\n\n  /**\n   * The label comes off the url as often as off a button, and the url is whatever somebody typed or\n   * linked, so a label no button offers falls back to All. Honoring it instead would empty the grid\n   * with no button looking active, which reads as a broken page rather than an empty filter. Only\n   * filters with matching cards get a button, so a picked button never empties the grid either.\n   */\n  private selectLabel(label: string) {\n    const buttons = this.shadowRoot.querySelectorAll('button');\n\n    this.selectedLabel = ALL_LABEL;\n    for (const button of buttons) {\n      if (button.dataset.label === label) {\n        this.selectedLabel = label;\n      }\n    }\n\n    this.renderButtons();\n    this.renderCards();\n  }\n\n  private renderButtons() {\n    const buttons = this.shadowRoot.querySelectorAll('button');\n    for (const button of buttons) {\n      button.setAttribute('aria-pressed', `${button.dataset.label === this.selectedLabel}`);\n    }\n  }\n\n  /**\n   * Hides every card outside the picked filter, and reveals the message that would otherwise only\n   * appear for an empty collection.\n   */\n  private renderCards() {\n    let selectedFilter: SalePageFilter;\n    const filters = this.querySelectorAll('sale-page-filter');\n    for (const filter of filters) {\n      if (filter.dataset.label === this.selectedLabel) {\n        selectedFilter = filter;\n        break;\n      }\n    }\n\n    const container = document.getElementById(this.dataset.productsContainerId);\n    const cards = container.querySelectorAll('product-card');\n\n    let visibleCount = 0;\n    for (const card of cards) {\n      const visible = !selectedFilter || matchesFilter(card, selectedFilter);\n      card.hidden = !visible;\n\n      if (visible) {\n        visibleCount++;\n      }\n    }\n\n    // The sale template also carries the collection page section for the A/B experiment, and it has\n    // a message with the same id, so the lookup stays inside the container.\n    const message = container.querySelector<HTMLElement>('#no-products-found');\n    if (message) {\n      message.hidden = visibleCount > 0;\n    }\n  }\n}\n\n/**\n * A card matches when any of the attributes the filter names holds any of its values.\n */\nfunction matchesFilter(card: ProductCard, filter: SalePageFilter) {\n  const values = splitTokens(filter.dataset.values);\n  const attributes = splitTokens(filter.dataset.filterAttributes);\n\n  if (attributes.includes('type') && values.includes(card.dataset.type)) {\n    return true;\n  }\n\n  if (attributes.includes('tags')) {\n    // The card's own data-tags only holds the namespaced tags the collection page filters use, so\n    // we read every tag off the product element instead. Its tags are as typed and the filter\n    // values are handles, so each tag is turned into a handle before comparing.\n    const productElement = card.querySelector<HTMLElementTagNameMap['product-element']>(\n      'product-element[slot=\"data\"]');\n    const tags = splitTokens(productElement.dataset.tags).map(toHandle);\n    return tags.some(tag => values.includes(tag));\n  }\n\n  return false;\n}\n\n/**\n * A rough match for Liquid's handleize, lowercasing a tag and turning each run of other characters\n * into a hyphen.\n */\nfunction toHandle(value: string) {\n  return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');\n}\n\n/**\n * Splits a space separated attribute into its tokens. An empty attribute splits into one empty\n * string, which would match any other empty attribute, so empty tokens are dropped.\n */\nfunction splitTokens(value: string) {\n  return value.split(' ').filter(token => token !== '');\n}\n\n/**\n * A shopper can arrive on a url that already names a filter, either by following a link to one or\n * by reloading a page they had filtered, so the url is read on connect and not only on a click.\n */\nfunction readLabelFromUrl() {\n  const url = new URL(location.href);\n  const labelFromUrl = url.searchParams.get(CATEGORY_PARAM);\n  if (labelFromUrl) {\n    return labelFromUrl;\n  } else {\n    return ALL_LABEL;\n  }\n}\n\n/**\n * The url is updated on a click, so the back button can step through the picks. The url is not\n * updated on a popstate, since the url already changed on its own.\n */\nfunction writeLabelToUrl(label: string) {\n  const url = new URL(location.href);\n\n  if (label === ALL_LABEL) {\n    url.searchParams.delete(CATEGORY_PARAM);\n  } else {\n    url.searchParams.set(CATEGORY_PARAM, label);\n  }\n\n  history.pushState(null, undefined, url.href);\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'sale-page-filters': SalePageFilters;\n  }\n}\n\nif (!customElements.get('sale-page-filters')) {\n  customElements.define('sale-page-filters', SalePageFilters);\n}\n"],"names":["event","CustomEvent","detail","all_products_count","collection_sale_page_section","collection","handle","id","products_count","title","url","dispatchEvent","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","exports","module","__webpack_modules__","CATEGORY_PARAM","SalePageFilters","HTMLElement","selectedLabel","onClickBound","this","onClick","bind","onPopStateBound","onPopState","connectedCallback","initializeShadowRoot","labelFromUrl","readLabelFromUrl","selectLabel","shadowRoot","querySelector","addEventListener","disconnectedCallback","removeEventListener","attachShadow","mode","innerHTML","appendFilterButtons","cards","document","getElementById","dataset","productsContainerId","querySelectorAll","fragment","createDocumentFragment","seenLabels","Set","filters","filter","has","label","add","hasMatchingCard","card","matchesFilter","slide","createElement","carouselSlide","button","type","setAttribute","textContent","appendChild","hidden","childElementCount","target","HTMLButtonElement","URL","location","href","searchParams","delete","set","history","pushState","writeLabelToUrl","_event","buttons","renderButtons","renderCards","selectedFilter","container","visibleCount","visible","message","values","splitTokens","attributes","filterAttributes","includes","tags","map","toHandle","some","tag","value","toLowerCase","replace","split","token","get","customElements","define"],"sourceRoot":""}