{"version":3,"file":"filters-drawer-section.min.js","mappings":"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","sources":["webpack://lange/./sections/filters-drawer/section-rendering.ts","webpack://lange/./sections/filters-drawer/index.ts","webpack://lange/./lib/dom-ready.ts"],"sourcesContent":["/**\n * The body of a Section Rendering API response. It maps each requested section id to the section's\n * html, or to null when the section could not be rendered.\n */\ntype SectionsResponseBody = Record<string, string>;\n\n/**\n * Fetches a section's html through Shopify's Section Rendering API, rendered in the context of the\n * page at the given url, search params included.\n *\n * Bots and other clients have sent requests that came back with bodies that were not the expected\n * json, so the body is checked at each step rather than trusted.\n *\n * @see https://shopify.dev/docs/api/section-rendering\n */\nexport async function fetchSection(sectionId: string, pageURL: URL) {\n  // A copy, so setting the sections param leaves the caller's url alone.\n  const url = new URL(pageURL);\n  if (url.origin !== location.origin) {\n    const error: any = new Error('Refusing to fetch section from another origin');\n    error.section_id = sectionId;\n    error.url = url.href;\n    throw error;\n  }\n\n  // Set rather than append, so a sections param already in the url cannot add other sections.\n  url.searchParams.set('sections', sectionId);\n\n  const response = await fetch(url);\n  if (!response.ok) {\n    const error: any = new Error('Failed to fetch section');\n    error.section_id = sectionId;\n    error.status = response.status;\n    throw error;\n  }\n\n  let body;\n  try {\n    body = await response.json();\n  } catch (error) {\n    const parseError: any = new Error('Failed to parse section body', { cause: error });\n    parseError.section_id = sectionId;\n    throw parseError;\n  }\n\n  if (!isSectionsResponseBody(body)) {\n    const error: any = new Error('Section body is not an object');\n    error.section_id = sectionId;\n    throw error;\n  }\n\n  const sectionHTML = body[sectionId];\n  if (!isValidSectionHTML(sectionId, sectionHTML)) {\n    const error: any = new Error('Section body has invalid html');\n    error.section_id = sectionId;\n    throw error;\n  }\n\n  return sectionHTML;\n}\n\nfunction isSectionsResponseBody(value: any): value is SectionsResponseBody {\n  return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/**\n * Checks that the html is the requested section. Shopify wraps a rendered section in an element\n * whose id is the section id prefixed with \"shopify-section-\". A section that failed to render\n * comes back as null, even with a 200 status.\n */\nfunction isValidSectionHTML(sectionId: string, sectionHTML: string) {\n  return typeof sectionHTML === 'string' &&\n    sectionHTML.includes(`id=\"shopify-section-${sectionId}\"`);\n}\n","import { ready } from '../../lib/dom-ready.js';\nimport { fetchSection } from './section-rendering.js';\n\ntype CollectionFiltersChangedEvent = WindowEventMap['collection-filters-changed'];\ntype FiltersDrawerOpenRequestedEvent = GlobalEventHandlersEventMap['filters-drawer-open-requested'];\ntype ModalClosedEvent = GlobalEventHandlersEventMap['modal-closed'];\n\n/**\n * The filters and sort chosen in the drawer, as url search params. It starts from the url, changes\n * as the shopper picks options, and is reset from the url when the filters change outside the\n * drawer. It holds every search param, not just the filters.\n */\nlet searchParams = new URLSearchParams(location.search);\n\nfunction onReady() {\n  // The open request originates from a button outside this drawer's subtree (it lives in another\n  // section), so a listener bound to the drawer element would never receive it. The request bubbles\n  // up to the document root instead, which is where this listens for it.\n  document.addEventListener('filters-drawer-open-requested', onFiltersDrawerOpenRequested);\n\n  const drawer = document.querySelector<HTMLElement>('#filters-drawer');\n  drawer.addEventListener('modal-closed', onModalClosed);\n  drawer.addEventListener('change', onOptionChanged);\n\n  const applyButton = drawer.querySelector<HTMLElement>('.apply-button');\n  applyButton.addEventListener('click', onApplyClicked);\n\n  const clearButton = drawer.querySelector<HTMLElement>('.clear-button');\n  clearButton.addEventListener('click', onClearClicked);\n\n  addEventListener('collection-filters-changed', onCollectionFiltersChanged);\n}\n\n/**\n * Records a picked sort option or a checked or unchecked filter. A filter change refreshes the\n * drawer's options, since their product counts depend on the other filters. A sort change leaves\n * the counts alone, so it waits for Apply.\n *\n * Each checkbox is named after its filter's url param, including the in stock toggle, whose Shopify\n * param is filter.v.availability with a value of \"1\". Unchecking it drops the param, so every\n * product shows regardless of availability.\n */\nfunction onOptionChanged(event: Event) {\n  const input = event.target;\n  if (input instanceof HTMLInputElement) {\n    if (input.type === 'radio') {\n      searchParams.set(input.name, input.value);\n    } else {\n      if (input.checked) {\n        searchParams.append(input.name, input.value);\n      } else {\n        searchParams.delete(input.name, input.value);\n      }\n\n      refresh();\n    }\n  }\n}\n\n/**\n * Announces the chosen filters and closes the drawer. The collection page section swaps in the\n * matching products.\n */\nfunction onApplyClicked(event: MouseEvent) {\n  dispatchApplied(event.currentTarget, searchParams);\n}\n\n/**\n * Applies the filters with every filter and the sort removed. The drawer's own options are left\n * alone here and pick up the cleared url once the collection page has swapped in the products.\n */\nfunction onClearClicked(event: MouseEvent) {\n  const params = new URLSearchParams(searchParams);\n\n  // The keys are copied into an array first, because deleting from the params while iterating them\n  // directly would skip entries.\n  for (const key of [...params.keys()]) {\n    // Shopify sorts by the sort_by param, and every filter param starts with \"filter.\".\n    if (key === 'sort_by' || key.startsWith('filter.')) {\n      params.delete(key);\n    }\n  }\n\n  dispatchApplied(event.currentTarget, params);\n}\n\nfunction dispatchApplied(target: EventTarget, params: URLSearchParams) {\n  const appliedEvent = new CustomEvent<FiltersAppliedEvent['detail']>('filters-applied', {\n    bubbles: true,\n    composed: true,\n    detail: {\n      search: params.toString()\n    }\n  });\n  target.dispatchEvent(appliedEvent);\n\n  const drawer = document.querySelector<HTMLElement>('#filters-drawer');\n  delete drawer.dataset.open;\n}\n\n/**\n * The collection page changed the filters in the url without a page load, such as from a pill\n * click, a clear, or the back button, so the drawer picks up the filters from the url again. After\n * Apply the drawer already has them.\n */\nfunction onCollectionFiltersChanged(_event: CollectionFiltersChangedEvent) {\n  const params = new URLSearchParams(location.search);\n  if (params.toString() !== searchParams.toString()) {\n    searchParams = params;\n    refresh();\n  }\n}\n\nfunction refresh() {\n  const backdrop = document.querySelector<HTMLElement>('#filters-drawer .backdrop');\n  backdrop.hidden = false;\n\n  update()\n    .catch(console.warn)\n    .finally(() => {\n      backdrop.hidden = true;\n    });\n}\n\n/**\n * Fetches this section with the drawer's search params through Shopify's Section Rendering API, and\n * copies which options are checked and disabled, and their product counts, onto the options in the\n * page. The options stay in place rather than being swapped, so focus stays on the one just\n * changed.\n */\nasync function update() {\n  const pageURL = new URL(location.href);\n  pageURL.search = searchParams.toString();\n  const sectionHTML = await fetchSection('filters-drawer', pageURL);\n\n  // A template keeps anything in the fetched html from loading.\n  const template = document.createElement('template');\n  template.innerHTML = sectionHTML;\n\n  const drawer = document.querySelector<HTMLElement>('#filters-drawer');\n  const fetchedInputs = template.content.querySelectorAll<HTMLInputElement>('.main input');\n  for (const fetchedInput of fetchedInputs) {\n    const name = CSS.escape(fetchedInput.name);\n    const value = CSS.escape(fetchedInput.value);\n    const input = drawer.querySelector<HTMLInputElement>(`input[name=\"${name}\"][value=\"${value}\"]`);\n    if (input) {\n      input.checked = fetchedInput.hasAttribute('checked');\n      input.disabled = fetchedInput.hasAttribute('disabled');\n\n      // For a filter value this is its label, which carries its product count.\n      input.nextElementSibling.textContent = fetchedInput.nextElementSibling.textContent;\n    }\n  }\n}\n\nfunction onFiltersDrawerOpenRequested(_event: FiltersDrawerOpenRequestedEvent) {\n  const drawer = document.querySelector<HTMLElement>('#filters-drawer');\n  drawer.dataset.open = 'true';\n  setTogglesExpanded(true);\n}\n\nfunction onModalClosed(_event: ModalClosedEvent) {\n  setTogglesExpanded(false);\n}\n\n/**\n * Reflects the drawer's open state onto every button that controls the drawer, so screen readers\n * announce the correct expanded state.\n *\n * Heads up: this reaches outside this section. The controlling buttons can be rendered by other\n * sections (for example the open button on the collection page), and this intentionally writes\n * their expanded state. Any section that renders a button controlling this drawer is relying on\n * this to keep that button in sync.\n */\nfunction setTogglesExpanded(expanded: boolean) {\n  const toggles = document.querySelectorAll('[aria-controls=\"filters-drawer\"]');\n  for (const toggle of toggles) {\n    toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');\n  }\n}\n\n/**\n * Fired when the shopper clicks Apply or Clear in the filters drawer. The search is the query\n * string, without the leading question mark, that the page url should change to.\n */\ntype FiltersAppliedEvent = CustomEvent<{ search: string; }>;\n\ndeclare global {\n  interface GlobalEventHandlersEventMap {\n    'filters-applied': FiltersAppliedEvent;\n    'filters-drawer-open-requested': Event;\n  }\n}\n\nready(onReady);\n","/**\n * Releases the handle returned from the ready() function.\n *\n * This is idempotent. Additional calls are pointless but harmless.\n */\nexport type ReleaseHandle = () => void;\n\n/**\n * A subset of events that pertain to document ready state.\n */\ntype LoadingEventMap = Pick<DocumentEventMap, 'DOMContentLoaded' | 'load'>;\n\n/**\n * Calls the provided callback when the document is ready. This works both when the document is\n * already ready and when it is not, so callers do not have to care. This exists because it ensures\n * that each caller consistently expresses its dependency on document readiness, and to save a bit\n * of boilerplate.\n *\n * A document is considered ready when it has been parsed. This corresponds to the ready state of\n * 'interactive'. When the document has been fully loaded, the ready state is 'complete'. The\n * callback runs either way, so callers do not have to check which one they are in.\n *\n * Ready means the html is parsed and the DOM is there to query. It does not mean the page's other\n * scripts have run. Deferred scripts and module scripts run after the ready state turns\n * 'interactive' and before DOMContentLoaded fires, so a callback here can run before them. When a\n * caller needs some other script to have run already, this is the wrong tool, and it should wait on\n * whatever that script produces instead.\n *\n * That remedy has a catch of its own when the calling bundle is loaded with async. An async bundle\n * can execute after DOMContentLoaded has already fired, so a plain listener for that event never\n * runs and the work silently never happens. Nothing here can detect that, because by the time this\n * file executes the event is long gone. Catching it needs something registered at parse time,\n * before any bundle loads.\n *\n * There is a second trap right next to that one, and it applies only when waiting for\n * DOMContentLoaded. The ready state stays 'interactive' after DOMContentLoaded has fired, all the\n * way until 'complete', so the ready state cannot tell you whether DOMContentLoaded is still coming\n * or has already gone. Waiting for load is not exposed to this, because the ready state turns\n * 'complete' and load fires in the same task, so seeing 'complete' means load has fired too.\n *\n * When the document is already ready, the callback is invoked via setTimeout. This way, if the\n * callback throws, the error surfaces to the global error handler in a way that is similar to what\n * happens when an event listener throws.\n *\n * We might one day want to move this to an NPM library.\n *\n * Beware of a quirk here. setTimeout yields, and the busier the frontend, the longer it takes to\n * fire. Page load is exactly when the frontend is busiest. The timeout is a floor and not a\n * guarantee, so there is no real bound on how long that yield lasts. What this means is that using\n * this library can introduce more delay than you might expect. Compare code that runs on async load\n * and checks ready state and invokes immediately to code that runs after setTimeout. The former\n * runs in the same tick, the latter runs in \"some later tick\". That later tick can be surprisingly\n * much later. That can cause initial paint issues.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event\n * @see https://html.spec.whatwg.org/multipage/parsing.html#the-end\n */\nexport function ready(callback: (event?: Event) => void,\n  type: keyof LoadingEventMap = 'DOMContentLoaded') {\n  if (document.readyState === 'complete' || (type === 'DOMContentLoaded' &&\n    document.readyState === 'interactive')) {\n    const timer = setTimeout(callback, 0);\n    const handle: ReleaseHandle = () => clearTimeout(timer);\n    return handle;\n  } else {\n    addEventListener(type, callback);\n    const handle: ReleaseHandle = () => removeEventListener(type, callback);\n    return handle;\n  }\n}\n"],"names":["async","fetchSection","sectionId","pageURL","url","URL","origin","location","error","Error","section_id","href","searchParams","set","response","fetch","ok","status","body","json","parseError","cause","value","Array","isArray","sectionHTML","includes","isValidSectionHTML","URLSearchParams","search","onOptionChanged","event","input","target","HTMLInputElement","type","name","checked","append","delete","refresh","onApplyClicked","dispatchApplied","currentTarget","onClearClicked","params","key","keys","startsWith","appliedEvent","CustomEvent","bubbles","composed","detail","toString","dispatchEvent","document","querySelector","dataset","open","onCollectionFiltersChanged","_event","backdrop","hidden","template","createElement","innerHTML","drawer","fetchedInputs","content","querySelectorAll","fetchedInput","CSS","escape","hasAttribute","disabled","nextElementSibling","textContent","update","catch","console","warn","finally","onFiltersDrawerOpenRequested","setTogglesExpanded","onModalClosed","expanded","toggles","toggle","setAttribute","callback","readyState","timer","setTimeout","clearTimeout","addEventListener","ready"],"sourceRoot":""}