PageSourceSearch

https://seattleslice.org/js/tabs.js?v=2026-08-21

js seattleslice.org collected 2026-10-04 22:22:25 UTC 9,123 bytes, 270 lines download raw bytes

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.