1// ===================== TAB BARS ===================== 2// A row of buttons with a filled pill that slides to whichever one is showing, 3// and the swap the panel under it does when the pill moves. 4// 5// The Sessions section had one of each written into it. There are three bars 6// now - the switch between the schedule and the session list, the run of time 7// slots under the schedule, and the formats under the session list - and they 8// should all behave the same way, so the behaviour lives here once. 9// 10// const bar = tabBar({ id: 'sessionTabs', label: 'Session formats' }); 11// bar.setTabs([{ key: 'panel', label: 'Panels' }], function(key, animate) { 12// ...show that format... 13// }); 14// bar.select('panel', false); 15// 16// The pill is measured from the button it sits behind, so it follows whatever 17// the labels have wrapped to at this width. Anything that changes those widths 18// has to say so with reflow() - a resize and the fonts arriving are handled 19// here, but a bar that was hidden while its buttons were built has no widths 20// to measure until it is shown. 21 22function tabBar(config) { 23 const settings = config || {}; 24 25 const element = document.createElement('div'); 26 element.className = 'session-tabs' + 27 (settings.className ? ' ' + settings.className : ''); 28 element.setAttribute('role', 'tablist'); 29 if (settings.id) element.id = settings.id; 30 if (settings.label) element.setAttribute('aria-label', settings.label); 31 element.hidden = true; 32 33 const glider = document.createElement('span'); 34 glider.className = 'session-tabs__glider'; 35 glider.setAttribute('aria-hidden', 'true'); 36 element.appendChild(glider); 37 38 let tabs = []; 39 // null rather than '', which is a key a tab can genuinely have: a session 40 // with nothing in its Format cell groups under the empty string, and a 41 // sentinel that collided with it would leave that tab unable to be selected. 42 let activeKey = null; 43 let onSelect = null; 44 let pending = false; 45 46 function panel() { 47 return settings.controls ? document.getElementById(settings.controls) : null; 48 } 49 50 // Instant while the window is being resized, or while a bar is arriving - 51 // it has no move of its own to make from wherever it was last left. 52 function moveGlider(instant) { 53 if (!tabs.length) return; 54 55 const active = tabs.filter(function(tab) { 56 return tab.dataset.tabKey === activeKey; 57 })[0]; 58 // A bar that is hidden, or whose fonts have not landed, has nothing worth 59 // measuring yet. It will be back through here when it is shown. 60 if (!active || !active.offsetWidth) return; 61 62 if (instant) glider.style.transition = 'none'; 63 glider.style.width = active.offsetWidth + 'px'; 64 glider.style.height = active.offsetHeight + 'px'; 65 glider.style.transform = 66 'translate(' + active.offsetLeft + 'px, ' + active.offsetTop + 'px)'; 67 if (instant) { 68 void glider.offsetWidth; 69 glider.style.transition = ''; 70 } 71 72 element.classList.add('session-tabs--glide'); 73 } 74 75 function sync(instant) { 76 const labelled = panel(); 77
78 tabs.forEach(function(tab) { 79 const on = tab.dataset.tabKey === activeKey; 80 tab.classList.toggle('is-active', on); 81 tab.setAttribute('aria-selected', on ? 'true' : 'false'); 82 // Roving focus: Tab reaches the row of buttons, the arrow keys move 83 // along it. 84 tab.tabIndex = on ? 0 : -1; 85 if (on && labelled) labelled.setAttribute('aria-labelledby', tab.id); 86 }); 87 88 moveGlider(instant); 89 } 90 91 function select(key, animate) { 92 if (key === activeKey) return false; 93 94 const known = tabs.some(function(tab) { 95 return tab.dataset.tabKey === key; 96 }); 97 if (!known) return false; 98 99 activeKey = key; 100 sync(!animate); 101 if (onSelect) onSelect(key, animate); 102 return true; 103 } 104 105 // Everything the bar was showing, replaced. The handler is what to do when 106 // one is pressed - the bar itself only moves the pill. 107 function setTabs(list, handler) { 108 onSelect = handler || null; 109 110 tabs.forEach(function(tab) { element.removeChild(tab); }); 111 tabs = []; 112 activeKey = null; 113 element.classList.remove('session-tabs--glide'); 114 115 list.forEach(function(entry) { 116 const tab = document.createElement('button'); 117 tab.type = 'button'; 118 tab.className = 'session-tab'; 119 tab.id = (settings.id || 'tabs') + '-' + 120 String(entry.key).replace(/[^A-Za-z0-9]+/g, ''); 121 tab.dataset.tabKey = entry.key; 122 tab.textContent = entry.label; 123 tab.setAttribute('role', 'tab'); 124 tab.setAttribute('aria-selected', 'false'); 125 if (settings.controls) tab.setAttribute('aria-controls', settings.controls); 126 tab.tabIndex = -1; 127 128 tab.addEventListener('click', function() { 129 select(entry.key, true); 130 }); 131 132 element.appendChild(tab); 133 tabs.push(tab); 134 }); 135 136 element.hidden = !tabs.length; 137 } 138 139 // The buttons arriving one after another, which is what a bar does when its 140 // view is switched to. Held back from the first paint so a bar that is only 141 // being shown again does not flash at full strength before it starts. 142 function playIn(stagger, cap) {
143 tabs.forEach(function(tab, at) { 144 tab.classList.remove('session-tab--in'); 145 void tab.offsetWidth; 146 tab.style.animationDelay = Math.min(at * stagger, cap) + 'ms'; 147 tab.classList.add('session-tab--in'); 148 }); 149 } 150 151 element.addEventListener('keydown', function(e) { 152 const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; 153 if (!step || !tabs.length) return; 154 e.preventDefault(); 155 156 let at = 0; 157 tabs.forEach(function(tab, i) { 158 if (tab.dataset.tabKey === activeKey) at = i; 159 }); 160 161 const next = tabs[(at + step + tabs.length) % tabs.length]; 162 next.focus(); 163 select(next.dataset.tabKey, true); 164 }); 165 166 // The buttons move when the row rewraps, and again when Bungee arrives and 167 // every label changes width, so the pill is placed again for both. 168 window.addEventListener('resize', function() { 169 if (pending) return; 170 pending = true; 171 window.requestAnimationFrame(function() { 172 pending = false; 173 moveGlider(true); 174 }); 175 }); 176 177 if (document.fonts && document.fonts.ready) { 178 document.fonts.ready.then(function() { moveGlider(true); }); 179 } 180 181 return { 182 element: element, 183 setTabs: setTabs, 184 select: select, 185 playIn: playIn, 186 reflow: function() { moveGlider(true); }, 187 active: function() { return activeKey; }, 188 count: function() { return tabs.length; } 189 }; 190} 191 192function tabsReducedMotion() { 193 return window.matchMedia && 194 window.matchMedia('(prefers-reduced-motion: reduce)').matches; 195} 196 197// How long the items take to leave, the step between the ones arriving, and 198// how long a box spends resizing between the two sets. 199const TAB_SWAP = { 200 outMs: 120, 201 staggerMs: 24, 202 staggerMaxMs: 180, 203 heightMs: 350 204}; 205 206// Swap what a box is showing: the old set leaves together, the new set arrives 207// one after another, and the box carries its own height across the two so 208// nothing below it jumps when the second set is the taller. 209// 210// `show` is handed a flag saying whether the arrivals should be stepped in, and 211// is the only thing that knows which items belong to what. 212// 213// Returns the timers it set, so a second press part way through can call them 214// off - see tabSwapCancel. 215function tabSwap(box, show, config) { 216 const settings = config || {}; 217 const items = settings.itemSelector || '.session-row'; 218 const outClass = settings.outClass || 'session-row--out'; 219 const timers = { out: 0, settle: 0 }; 220 221 if (!settings.animate || tabsReducedMotion()) { 222 box.classList.remove('is-swapping'); 223 box.style.height = ''; 224 show(false); 225 return timers; 226 } 227 228 // What the box stands at now. The next set is only measured once the old one 229 // has gone, so the two heights are the ends of the move. 230 const startHeight = box.offsetHeight; 231 232 box.querySelectorAll(items + ':not([hidden])').forEach(function(item) { 233 item.style.animationDelay = ''; 234 item.classList.add(outClass); 235 }); 236 237 timers.out = setTimeout(function() { 238 box.classList.remove('is-swapping'); 239 box.style.height = ''; 240 show(true); 241 242 const endHeight = box.offsetHeight; 243 box.classList.add('is-swapping'); 244 box.style.height = startHeight + 'px'; 245 void box.offsetHeight; 246 box.style.height = endHeight + 'px'; 247 248 // Clipping is only wanted while the height is moving - at rest the rows 249 // need the room outside the box for their hover glow. A timer rather than 250 // transitionend, which goes missing when a second tab is pressed part way 251 // through. 252 timers.settle = setTimeout(function() { 253 box.classList.remove('is-swapping'); 254 box.style.height = ''; 255 }, TAB_SWAP.heightMs + 60); 256 }, TAB_SWAP.outMs); 257 258 return timers; 259} 260 261function tabSwapCancel(timers) { 262 if (!timers) return; 263 clearTimeout(timers.out); 264 clearTimeout(timers.settle); 265} 266 267// The delay an item arriving at position `at` waits before it starts 268function tabSwapDelay(at) { 269 return Math.min(at * TAB_SWAP.staggerMs, TAB_SWAP.staggerMaxMs) + 'ms'; 270}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.