{"version":3,"file":"index.cjs","sources":["../src/util.ts","../src/announcements.ts","../src/focus.ts","../src/index.ts"],"sourcesContent":["export function createElement(html: string): HTMLElement {\n\tconst template = document.createElement('template');\n\ttemplate.innerHTML = html;\n\treturn template.content.children[0] as HTMLElement;\n}\n\nexport function parseTemplate(str: string, replacements: Record<string, string>): string {\n\treturn Object.keys(replacements).reduce((str, key) => {\n\t\treturn str.replace(`{${key}}`, replacements[key] || '');\n\t}, str || '');\n}\n\nexport function prefersReducedMotion(): boolean {\n\treturn window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n","import { Location } from 'swup';\nimport { AnnouncementTranslations, Options } from './index.js';\nimport { createElement, parseTemplate } from './util.js';\n\nexport class Announcer {\n\tid: string = 'swup-announcer';\n\tstyle: string = `position:absolute;top:0;left:0;clip:rect(0 0 0 0);clip-path:inset(50%);overflow:hidden;white-space:nowrap;word-wrap:normal;width:1px;height:1px;`;\n\tregion: HTMLElement;\n\n\tconstructor() {\n\t\tthis.region = this.getRegion() ?? this.createRegion();\n\t}\n\n\tgetRegion(): HTMLElement | null {\n\t\treturn document.getElementById(this.id);\n\t}\n\n\tcreateRegion(): HTMLElement {\n\t\tconst liveRegion = createElement(\n\t\t\t`<p aria-live=\"assertive\" aria-atomic=\"true\" id=\"${this.id}\" style=\"${this.style}\"></p>`\n\t\t);\n\t\tdocument.body.appendChild(liveRegion);\n\t\treturn liveRegion;\n\t}\n\n\tannounce(message: string, delay: number = 0): Promise<void> {\n\t\treturn new Promise((resolve) => {\n\t\t\tsetTimeout(() => {\n\t\t\t\t// Make each message unique to allow reading identical page titles twice in a row\n\t\t\t\tif (this.region.textContent === message) {\n\t\t\t\t\tmessage = `${message}.`;\n\t\t\t\t}\n\t\t\t\t// Clear before announcing\n\t\t\t\tthis.region.textContent = '';\n\t\t\t\tthis.region.textContent = message;\n\n\t\t\t\tresolve();\n\t\t\t}, delay);\n\t\t});\n\t}\n}\n\nexport type PageAnnouncementOptions = Pick<Options, 'headingSelector' | 'announcements'>;\n\nexport function getPageAnnouncement({\n\theadingSelector = 'h1',\n\tannouncements = {}\n}: PageAnnouncementOptions): string | undefined {\n\tconst lang = document.documentElement.lang || '*';\n\tconst { href, url, pathname: path } = Location.fromUrl(window.location.href);\n\n\tconst templates =\n\t\t(announcements as AnnouncementTranslations)[lang] ??\n\t\t(announcements as AnnouncementTranslations)['*'] ??\n\t\tannouncements;\n\tif (typeof templates !== 'object') return;\n\n\t// Look for first heading on page\n\tconst headingEl = document.querySelector(headingSelector);\n\tif (!headingEl) {\n\t\tconsole.warn(`SwupA11yPlugin: No main heading (${headingSelector}) found on new page`);\n\t}\n\n\t// Get page heading from aria attribute or text content\n\tconst heading = headingEl?.getAttribute('aria-label') || headingEl?.textContent;\n\n\t// Fall back to document title, then url if no title was found\n\tconst title = heading || document.title || parseTemplate(templates.url, { href, url, path });\n\n\t// Replace {variables} in template\n\treturn parseTemplate(templates.visit, { title, href, url, path });\n}\n","export function focusElement(elementOrSelector: string | HTMLElement) {\n\tlet el: HTMLElement | null;\n\tif (typeof elementOrSelector === 'string') {\n\t\tel = document.querySelector<HTMLElement>(elementOrSelector);\n\t} else {\n\t\tel = elementOrSelector;\n\t}\n\n\tif (!(el instanceof HTMLElement)) return;\n\n\t// Apply a tabindex attribute to allow focusing non-focusable elements\n\tconst tabindex = el.getAttribute('tabindex');\n\tel.setAttribute('tabindex', '-1');\n\tel.focus({ preventScroll: true });\n\tif (tabindex !== null) {\n\t\tel.setAttribute('tabindex', tabindex);\n\t} else {\n\t\t// Removing the tabindex will reset screen reader position, so we'll keep it\n\t\t// el.removeAttribute('tabindex');\n\t}\n}\n\nexport function focusAutofocusElement(): boolean {\n\tconst autofocusEl = getAutofocusElement();\n\tif (!autofocusEl) return false;\n\n\tif (autofocusEl !== document.activeElement) {\n\t\t// Only focus if not already focused\n\t\t// No preventScroll flag here, as probably intended with autofocus\n\t\tautofocusEl.focus();\n\t}\n\treturn true;\n}\n\nexport function getAutofocusElement(): HTMLElement | undefined {\n\tconst focusEl = document.querySelector<HTMLElement>('body [autofocus]');\n\tif (focusEl && !focusEl.closest('[inert], [aria-disabled], [aria-hidden=\"true\"]')) {\n\t\treturn focusEl;\n\t}\n}\n","import { HookHandler, Visit } from 'swup';\nimport Plugin from '@swup/plugin';\n\nimport 'focus-options-polyfill';\n\nimport { Announcer, getPageAnnouncement } from './announcements.js';\nimport { focusAutofocusElement, focusElement } from './focus.js';\nimport { prefersReducedMotion } from './util.js';\n\nexport interface VisitA11y {\n\t/** How to announce the new content after it inserted */\n\tannounce: string | false | undefined;\n\t/** The element to focus after the content is replaced */\n\tfocus: string | false;\n}\n\ndeclare module 'swup' {\n\texport interface Visit {\n\t\t/** Accessibility settings for this visit */\n\t\ta11y: VisitA11y;\n\t}\n\texport interface HookDefinitions {\n\t\t'content:announce': undefined;\n\t\t'content:focus': undefined;\n\t}\n\texport interface Swup {\n\t\t/**\n\t\t * Announce something programmatically\n\t\t */\n\t\tannounce?: SwupA11yPlugin['announce'];\n\t}\n}\n\n/** Templates for announcements of the new page content. */\nexport type Announcements = {\n\t/** How to announce the new page. */\n\tvisit: string;\n\t/** How to read a page url. Used as fallback if no heading was found. */\n\turl: string;\n};\n\n/** Translations of announcements, keyed by language. */\nexport type AnnouncementTranslations = {\n\t[lang: string]: Announcements;\n};\n\nexport type Options = {\n\t/** The selector for finding headings inside the main content area. */\n\theadingSelector: string;\n\t/** Whether to skip animations for users that prefer reduced motion. */\n\trespectReducedMotion: boolean;\n\t/** How to announce the new page title and url. */\n\tannouncements: Announcements | AnnouncementTranslations;\n\t/** Whether to focus elements with an [autofocus] attribute after navigation. */\n\tautofocus: boolean;\n};\n\nexport default class SwupA11yPlugin extends Plugin {\n\tname = 'SwupA11yPlugin';\n\n\trequires = { swup: '>=4' };\n\n\tdefaults: Options = {\n\t\theadingSelector: 'h1',\n\t\trespectReducedMotion: true,\n\t\tautofocus: false,\n\t\tannouncements: {\n\t\t\tvisit: 'Navigated to: {title}',\n\t\t\turl: 'New page at {url}'\n\t\t}\n\t};\n\n\toptions: Options;\n\n\t/**\n\t * The announcer instance for reading new page content.\n\t */\n\tannouncer: Announcer;\n\n\t/**\n\t * The delay before announcing new page content.\n\t * Why 100ms? see research at https://github.com/swup/a11y-plugin/pull/50\n\t */\n\tannouncementDelay: number = 100;\n\n\t/**\n\t * The selector for the main content area of the page, to focus after navigation.\n\t */\n\trootSelector: string = 'body';\n\n\tconstructor(options: Partial<Options> = {}) {\n\t\tsuper();\n\n\t\t// Merge default options with user defined options\n\t\tthis.options = { ...this.defaults, ...options };\n\n\t\t// Create announcer instance for announcing new page content\n\t\tthis.announcer = new Announcer();\n\t}\n\n\tmount() {\n\t\t// Prepare new hooks\n\t\tthis.swup.hooks.create('content:announce');\n\t\tthis.swup.hooks.create('content:focus');\n\n\t\t// Prepare visit by adding a11y settings to visit object\n\t\tthis.before('visit:start', this.prepareVisit);\n\n\t\t// Mark page as busy during transitions\n\t\tthis.on('visit:start', this.markAsBusy);\n\t\tthis.on('visit:end', this.unmarkAsBusy);\n\n\t\t// Focus new page content after visit completes\n\t\tthis.on('visit:end', this.focusContent);\n\n\t\t// Announce new page title after visit completes\n\t\tthis.on('visit:end', this.announceContent);\n\n\t\t// Move focus start point when clicking on-page anchors\n\t\tthis.on('scroll:anchor', this.handleAnchorScroll);\n\n\t\t// Disable transition and scroll animations if user prefers reduced motion\n\t\tthis.before('visit:start', this.disableAnimations);\n\t\tthis.before('link:self', this.disableAnimations);\n\t\tthis.before('link:anchor', this.disableAnimations);\n\n\t\t// Announce something programmatically\n\t\tthis.swup.announce = this.announce.bind(this);\n\t}\n\n\tunmount() {\n\t\tthis.swup.announce = undefined;\n\t}\n\n\tasync announce(message: string): Promise<void> {\n\t\tawait this.announcer.announce(message);\n\t}\n\n\tmarkAsBusy() {\n\t\tdocument.documentElement.setAttribute('aria-busy', 'true');\n\t}\n\n\tunmarkAsBusy() {\n\t\tdocument.documentElement.removeAttribute('aria-busy');\n\t}\n\n\tprepareVisit(visit: Visit) {\n\t\tvisit.a11y = {\n\t\t\tannounce: undefined,\n\t\t\tfocus: this.rootSelector\n\t\t};\n\t}\n\n\tannounceContent(visit: Visit) {\n\t\tthis.swup.hooks.callSync('content:announce', visit, undefined, (visit) => {\n\t\t\t// Allow customizing announcement before this hook\n\t\t\tif (typeof visit.a11y.announce === 'undefined') {\n\t\t\t\tvisit.a11y.announce = this.getPageAnnouncement();\n\t\t\t}\n\n\t\t\tif (!visit.a11y.announce) return;\n\n\t\t\tthis.announcer.announce(visit.a11y.announce, this.announcementDelay);\n\t\t});\n\t}\n\n\tfocusContent(visit: Visit) {\n\t\tthis.swup.hooks.callSync('content:focus', visit, undefined, (visit) => {\n\t\t\tif (!visit.a11y.focus) return;\n\n\t\t\t// Found and focused [autofocus] element? Return early\n\t\t\tif (this.options.autofocus && focusAutofocusElement() === true) return;\n\n\t\t\t// Otherwise, find and focus actual content container\n\t\t\tfocusElement(visit.a11y.focus);\n\t\t});\n\t}\n\n\thandleAnchorScroll: HookHandler<'scroll:anchor'> = (visit, { hash }) => {\n\t\tconst anchor = this.swup.getAnchorElement(hash);\n\t\tif (anchor instanceof HTMLElement) {\n\t\t\tfocusElement(anchor);\n\t\t}\n\t};\n\n\tgetPageAnnouncement(): string | undefined {\n\t\tconst { headingSelector, announcements } = this.options;\n\t\treturn getPageAnnouncement({ headingSelector, announcements });\n\t}\n\n\tdisableAnimations(visit: Visit) {\n\t\tif (this.options.respectReducedMotion && prefersReducedMotion()) {\n\t\t\tvisit.animation.animate = false;\n\t\t\t// @ts-expect-error: animate is undefined unless Scroll Plugin installed\n\t\t\tvisit.scroll.animate = false;\n\t\t}\n\t}\n}\n"],"names":["parseTemplate","str","replacements","Object","keys","reduce","key","replace","Announcer","constructor","this","id","style","region","getRegion","createRegion","document","getElementById","liveRegion","html","template","createElement","innerHTML","content","children","body","appendChild","announce","message","delay","Promise","resolve","setTimeout","textContent","focusElement","elementOrSelector","el","querySelector","HTMLElement","tabindex","getAttribute","setAttribute","focus","preventScroll","Plugin","options","super","name","requires","swup","defaults","headingSelector","respectReducedMotion","autofocus","announcements","visit","url","announcer","announcementDelay","rootSelector","handleAnchorScroll","hash","anchor","getAnchorElement","mount","hooks","create","before","prepareVisit","on","markAsBusy","unmarkAsBusy","focusContent","announceContent","disableAnimations","bind","unmount","undefined","then","e","reject","documentElement","removeAttribute","a11y","callSync","getPageAnnouncement","autofocusEl","focusEl","closest","getAutofocusElement","activeElement","focusAutofocusElement","lang","href","pathname","path","Location","fromUrl","window","location","templates","headingEl","console","warn","title","matchMedia","matches","animation","animate","scroll"],"mappings":"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"}