/**
* This work is licensed under the W3C Software and Document License
* (http://www.w3.org/Consortium/Legal/2015/copyright-software-and-document).
*/
class MediaAffordancesElement extends HTMLElement {
constructor() {
super()
this.mqls = []
this.observers = []
this.supportedAffordances = new Set()
}
observeAffordanceChange(cb) {
this.observers.push(cb)
}
notifyChange() {
let intersection = new Set()
for (let elem of this.mqls) {
if (elem.matches && this.supportedAffordances.has(elem.__affordance)) {
intersection.add(elem.__affordance)
}
}
let arr = [...intersection]
if (arr.length > 0) {
this.setAttribute('mq-matched', arr.join(' '))
} else {
this.removeAttribute('mq-matched')
}
let affordance = arr[0]
if (affordance) {
this.setAttribute('affordance', affordance)
} else {
this.removeAttribute('affordance')
}
this.observers.forEach((cb) => {
cb(intersection, this.__matching)
})
}
static get observedAttributes() {
return ['mq-affordances']
}
connectedCallback() {
let newValue = getComputedStyle(this).getPropertyValue('--const-mq-affordances')
this.connectListeners(newValue)
}
connectListeners(newValue = '') {
if (newValue.trim().length === 0) {
return
}
newValue.split('|').forEach((segment) => {
let mq = segment.trim().match(/\[([^\]]*)/)[1]
let names = segment.replace(`[${mq}]`, '').trim().split(' ')
let mql = window.matchMedia(mq)
mql.__affordance = names[0] // one for now
mql.onchange = () => this.notifyChange()
this.mqls.push(mql)
}, this)
this.notifyChange()
}
attributeChangedCallback(name, oldValue, newValue) {
this.connectListeners(newValue)
}
}
// ----------------------------------------------------
(() => {
let lastUId = 0
let nextUId = () => {
return `cp${++lastUId}`
}
let getLabels = (regionset) => {
return [...regionset.children].filter((el) => /^H\d$/.test(el.tagName) || el.tagName === 'SPICY-H')
}
let getContentEls = (regionset) => {
return [...regionset.children].filter((el) => !/^H\d$/.test(el.tagName))
}
let ensureId = (el) => {
el.id = el.id || nextUId()
return el.id
}
let style = document.createElement('style')
style.innerHTML = `
:where(spicy-sections > [affordance*="collapse"])::before {
content: ' ';
display: inline-block;
width: 0.5em;
height: 0.75em;
margin: 0 0.4em 0 0;
transform: rotate(90deg);
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='10px' height='10px' viewBox='0 0 270 240' enable-background='new 0 0 270 240' xml:space='preserve'%3e%3cpolygon fill='black' points='5,235 135,10 265,235 '/%3e%3c/svg%3e ");
background-size: 100% 100%;
}
:where(spicy-sections > .hide) {
display: none !important;
}
:where(spicy-sections > [affordance*="collapse"][aria-expanded="true"])::before,
:where(spicy-sections > [affordance*="collapse"][aria-expanded="true"])::after {
transform: rotate(180deg);
}
`
document.head.prepend(style)
const template = `
`
class RegionSet extends MediaAffordancesElement {
__defaults
__tabListEl
// tabs and exclusive collapses should have the same affordance object?
__affordanceConf = {
collapse: {
// can take a condition to force, check-like
toggle: (label, condition) => {
let state = typeof condition === 'boolean' ? condition : !label.affordanceState.expanded
let contentEl = label.nextElementSibling
label.affordanceState.expanded = state
label.affordanceState.nonExclusiveExpanded = state
label.setAttribute('aria-expanded', state)
if (state) {
label.setAttribute('expanded', '')
contentEl.classList.remove('hide')
} else {
label.removeAttribute('expanded')
contentEl.classList.add('hide')
}
},
},
'exclusive-collapse': {
// ignores condition, radio-like
toggle: (label) => {
let labels = getLabels(label.parentElement)
let siblings = labels.filter((c) => c !== label)
let index = labels.findIndex((c) => c === label)
siblings.forEach((sibLabel, i) => {
let relatedContent = sibLabel.nextElementSibling
relatedContent.classList.add('hide')
sibLabel.tabIndex = -1
sibLabel.setAttribute('aria-expanded', 'false')
sibLabel.affordanceState.exclusiveExpanded = false
})
label.tabIndex = 0
label.parentElement.affordanceState.exclusiveSelection.index = index // TODO: nope, fix/remove this?
label.nextElementSibling.classList.remove('hide')
label.setAttribute('aria-expanded', 'true')
label.affordanceState.exclusiveExpanded = true
label.focus()
},
},
'tab-bar': {
// ignores condition, radio-like
toggle: (label) => {
let labels = getLabels(label.parentElement)
let siblings = labels.filter((c) => c !== label)
let index = labels.findIndex((c) => c === label)
siblings.forEach((sibLabel, i) => {
let relatedContent = sibLabel.nextElementSibling
relatedContent.classList.add('hide')
sibLabel.tabIndex = -1
sibLabel.setAttribute('aria-selected', 'false')
sibLabel.affordanceState.exclusiveExpanded = false
})
label.tabIndex = 0
label.parentElement.affordanceState.exclusiveSelection.index = index
label.nextElementSibling.classList.remove('hide')
label.setAttribute('aria-selected', 'true')
label.affordanceState.exclusiveExpanded = true
label.focus()
},
},
}
__setSize = (labelEls, contentEls) => {
this.__size = Math.min(labelEls.length, contentEls.length)
if (labelEls.length !== this.__size) {
console.warn('mismatch in tab-set label/content pairs...')
}
labelEls.forEach((labelEl, i) => {
let contentEl = contentEls[i]
if (!labelEl.initialized) {
labelEl.initialized = true // TODO: this used to be shadow, do i need it?
let defs = this.__defaults.defaultActive
// this assumes it is about collapses
labelEl.affordanceState = {
expanded: defs.includes(labelEl),
active: false,
// activate in the current mode
activate: () => {
if (this.affordanceState.current) {
this.__affordanceConf[this.affordanceState.current].toggle(labelEl)
}
},
}
let defaultExclusive = defs.length === 0 ? labelEls[0] : defs[defs.length - 1]
this.affordanceState.exclusiveSelection.index = labelEls.indexOf(defaultExclusive)
}
labelEl.setMode = (mode) => {
if (mode === 'non-exclusive') {
let isExpanded = labelEl.affordanceState.expanded
labelEl.setAttribute('affordance', 'collapse')
labelEl.setAttribute('tabindex', '0')
labelEl.setAttribute('aria-controls', contentEl.id)
labelEl.setAttribute('role', 'button')
labelEl.setAttribute('aria-expanded', isExpanded)
labelEl.nextElementSibling.classList.toggle('hide', !isExpanded)
} else if (mode === 'exclusive') {
let isExpanded = labelEls.indexOf(labelEl) === this.affordanceState.exclusiveSelection.index
labelEl.setAttribute('affordance', 'collapse')
labelEl.setAttribute('tabindex', isExpanded ? 0 : -1)
labelEl.setAttribute('role', 'button')
labelEl.setAttribute('aria-expanded', isExpanded)
labelEl.setAttribute('aria-controls', contentEl.id)
labelEl.nextElementSibling.classList.toggle('hide', !isExpanded)
} else {
labelEl.removeAttribute('tabIndex')
labelEl.removeAttribute('affordance')
labelEl.removeAttribute('aria-expanded')
labelEl.removeAttribute('role')
}
}
})
}
__projectTabBar = () => {
this.__removeProjections()
getLabels(this).forEach((tabSource, i) => {
let selected = false
let tabIndex = -1
tabSource.setMode()
tabSource.slot = 'tabListSlot'
tabSource.setAttribute('role', 'tab')
let contentSource = tabSource.nextElementSibling
contentSource.tabIndex = 0
tabSource.setAttribute('aria-controls', ensureId(contentSource))
contentSource.setAttribute('role', 'tabpanel')
contentSource.setAttribute('aria-labelledby', tabSource.id)
if (i === this.affordanceState.exclusiveSelection.index) {
tabIndex = 0
selected = true
}
tabSource.setAttribute('aria-selected', selected)
tabSource.tabIndex = tabIndex
contentSource.classList.toggle('hide', !selected)
// TODO: aria-orientation :(
})
}
__projectCollapses = (exclusive) => {
// TODO: remove projections and... ??
this.__removeProjections()
getLabels(this).forEach((label) => {
label.setMode(exclusive ? 'exclusive' : 'non-exclusive')
})
}
__removeProjections = () => {
Array.from(this.children, (child) => {
child.removeAttribute('slot')
child.removeAttribute('affordance')
child.removeAttribute('role')
child.removeAttribute('aria-selected')
child.removeAttribute('aria-controls')
child.removeAttribute('tabindex')
child.removeAttribute('aria-expanded')
child.classList.remove('hide')
})
}
// matching pairs
__size = 0
__configure = () => {
// hmmm
this.__setSize(getLabels(this), getContentEls(this))
if (this.affordanceState.current === 'tab-bar') {
this.affordanceState.currentMode = 'exclusive'
this.__projectTabBar()
} else if (this.affordanceState.current === 'collapse') {
this.affordanceState.currentMode = 'non-exclusive'
this.__projectCollapses()
} else if (this.affordanceState.current === 'exclusive-collapse') {
this.affordanceState.currentMode = 'exclusive'
this.__projectCollapses(true)
} else {
this.affordanceState.currentMode = undefined
this.__removeProjections()
}
// TODO: hmm, these are DOM changes, we could cache them
let labelEls = getLabels(this)
for (let i = 0; i < this.__size; i++) {
let label = labelEls[i]
// probably add one handler that decides
if (!label._inited) {
label.addEventListener('click', (evt) => {
evt.target.affordanceState.activate()
})
label._inited = true
}
}
}
__childListObserver = new MutationObserver((mutationList) => {
// we have to wire up new elements
let labelEls = getLabels(this)
let contentEls = getContentEls(this)
// what if there is a mismatch?
this.__setSize(labelEls, contentEls)
this.__configure()
})
__tabset
affordanceState = {
exclusiveSelection: { index: undefined },
current: undefined,
currentMode: undefined,
getLabels: () => {
return getLabels(this)
},
}
honourFragmentLink = () => {
let labels = getLabels(this)
if (location.hash && this.querySelector(location.hash)) {
// try to find a label with this ID, or controlled content
// that contains an element with this ID
for (let i = 0; i < labels.length; i++) {
let relevantContent = labels[i].getAttribute('aria-controls') && this.querySelector(`#${labels[i].getAttribute('aria-controls')}`)
if (labels[i] === this.querySelector(location.hash) || (relevantContent && relevantContent.querySelector(location.hash))) {
labels[i].affordanceState.activate()
return
}
}
}
}
// wires up supported affordances
constructor() {
super()
this.supportedAffordances.add('tab-bar')
this.supportedAffordances.add('collapse')
this.supportedAffordances.add('exclusive-collapse')
let checkDefaults = () => {
if (!this.__defaults) {
this.__defaults = {
onMatch: this.hasAttribute('defaults-on-match'),
defaultActive: getLabels(this).filter((l) => l.hasAttribute('default-activate')),
}
}
}
this.observeAffordanceChange((matching, all) => {
if (!this.__defaults) {
this.__defaults = {
onMatch: this.hasAttribute('defaults-on-match'),
defaultActive: getLabels(this).filter((l) => l.hasAttribute('default-activate')),
}
}
this.affordanceState.current = this.getAttribute('affordance')
this.__configure()
})
this.setActiveAffordance = (matching, all) => {
checkDefaults()
this.setAttribute('affordance', matching)
this.affordanceState.current = matching
this.__configure()
}
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = template
this.__tabListEl = this.shadowRoot.querySelector("[part='tab-list']")
this.addEventListener('keydown', (evt) => {
let labels = getLabels(this)
let size = labels.length
let cur = this.affordanceState.exclusiveSelection.index
let prev = cur === 0 ? size - 1 : cur - 1
let next = cur === size - 1 ? 0 : cur + 1
// don't trap nested handling
if (evt.target.parentElement !== evt.currentTarget) {
return
}
if (this.affordanceState.current === 'tab-bar' || this.affordanceState.current === 'exclusive-collapse') {
if (evt.key === 'ArrowLeft' || evt.key === 'ArrowUp') {
labels[prev].affordanceState.activate()
evt.preventDefault()
} else if (evt.key === 'ArrowRight' || evt.key === 'ArrowDown') {
labels[next].affordanceState.activate()
evt.preventDefault()
}
} else if (evt.key === ' ' && this.affordanceState.current === 'collapse') {
evt.preventDefault()
}
}, false)
this.addEventListener('keyup', (evt) => {
if (evt.key === ' ' && this.affordanceState.current === 'collapse') {
evt.target.closest('[affordance]').affordanceState.activate()
evt.preventDefault()
}
})
}
connectedCallback() {
super.connectedCallback()
// TODO: handle selection
if (location.hash) {
setTimeout(this.honourFragmentLink, 1)
}
window.addEventListener('hashchange', this.honourFragmentLink)
// if you append a fragment with a pair, it should work
this.__childListObserver.observe(this, { childList: true })
}
}
customElements.define('spicy-sections', RegionSet)
})()