Keyboard Navigation

Keyboard Navigation and Focus Order

The roving tabindex pattern makes the chart one tab stop: only the current bar has tabindex="0", arrow keys, Home and End move focus inside, and an aria-live region announces each value. A script focuses the first bar, then presses ArrowDown and End:

Roving tabindex, arrow keys and a live region on the accessible chart (sample data)HTML
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script src="booknest-sales.js"></script><script src="booknest-prep.js"></script>
<script src="booknest-a11y.js"></script>
<style>g:focus { outline: none } g:focus rect { stroke: #263238; stroke-width: 3 }</style>
<h3 id="chart-title" style="font:14px Arial;margin:0">Mean star rating per book</h3>
<p id="chart-summary" style="font:12px Arial;margin:0">Salt and Saffron rates highest.</p>
<svg width="560" height="150" font-family="Arial" font-size="12"></svg>
<p id="live" aria-live="polite" style="font:12px Arial;margin:4px 0"></p>
<script>
  const bars = accessibleRatings(d3.select('svg'), prepareSales(SALES).ratings);
  const nodes = bars.nodes();
  bars.attr('tabindex', (d, i) => i === 0 ? 0 : -1)
    .on('focus', event => d3.select('#live').text(event.currentTarget.ariaLabel))
    .on('keydown', event => {
      const i = nodes.indexOf(event.currentTarget), last = nodes.length - 1;
      const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: last }[event.key];
      if (next === undefined) return;                  // let Tab and other keys through
      event.preventDefault();                          // no page scrolling
      const target = nodes[Math.max(0, Math.min(last, next))];
      nodes.forEach(n => n.setAttribute('tabindex', n === target ? 0 : -1));
      target.focus();
    });
  nodes[0].focus();                                    // simulate ArrowDown, then End
  for (const key of ['ArrowDown', 'End']) document.activeElement
    .dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
  window.__done = true;
</script>
Browser output of Listing 6.52
Browser output of 52

Focus moved through The Quiet Harbor to the last bar. Append marks in reading order, since focus follows the DOM, and draw a visible focus stroke, since SVG ignores most outlines.

A roving tabindex makes the bar chart one tab stop; arrow keys, Home and End move focus insideHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
  svg { width: 100%; max-width: 560px; display: block; }
  g:focus { outline: none; }
  g:focus rect { stroke: #263238; stroke-width: 3; }         /* SVG ignores most outlines: draw one */
  h3 { font-size: 14px; margin: 0; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<h3 id="chart-title">Mean star rating per book</h3>
<p id="chart-summary" style="margin:0">Salt and Saffron rates highest. Tab into the chart, then use ↑ ↓ Home End.</p>
<svg viewBox="0 0 560 150" font-size="12"></svg>
<p id="live" aria-live="polite" style="font-weight:bold"></p>
<script>
  // BookNest sample data, invented for these examples (not real sales)
  const SALES = (() => {
    const books = [
      { id: 1, key: 'harbor', title: 'The Quiet Harbor', author: 'Elena Marsh', genre: 'Fiction', color: '#1f5f8b', price: 18.99, rating: 4.6 },
      { id: 2, key: 'deepweb', title: 'Patterns of the Deep Web', author: 'Ravi Menon', genre: 'Technology', color: '#5b3f99', price: 39.5, rating: 4.3 },
      { id: 3, key: 'saffron', title: 'Salt and Saffron', author: 'Leila Haddad', genre: 'Cooking', color: '#e09a10', price: 24, rating: 4.8 },
      { id: 4, key: 'summit', title: 'Small Steps to Big Summits', author: 'Tom Okafor', genre: 'Self-Help', color: '#3f7d3a', price: 14.99, rating: 4.1 },
      { id: 5, key: 'clock', title: "The Clockmaker's Paradox", author: 'Iris Vale', genre: 'Science Fiction', color: '#b5452f', price: 16.2, rating: 4.5 },
      { id: 6, key: 'glass', title: 'Gardens in Glass', author: 'June Park', genre: 'Gardening', color: '#2a9d8f', price: 22.5, rating: 4.4 },
    ];
    const units = {        // units sold per month, January 2025 to August 2026
      1: [40, 47, 46, 47, 57, 51, 46, 54, 60, 60, 71, 111, 52, 42, 49, 56, 59, 48, 53, 51],
      2: [40, 37, 35, 41, 48, 43, 40, 42, 50, 53, 53, 84, 42, 35, 38, 41, 45, 41, 46, 46],
      3: [46, 45, 41, 46, 53, 49, 46, 45, 60, 51, 63, 100, 48, 48, 49, 48, 53, 49, 48, 52],
      4: [100, 80, 72, 70, 66, 66],                    // released March 2026
      5: [48, 47, 54, 52, 65, 55, 57, 58, 64, 66, 75, 124, 58, 49, 59, 56, 63, 57, 54, 57],
      6: [31, 30, 28, 35, 31, 32, 29, 30, 41, 36, 45, 64, 31, 30, 30, 38, 40, 38, 36, 38],
    };
    const months = d3.utcMonths(new Date('2025-01-01'), new Date('2026-09-01')).map(d3.utcFormat('%Y-%m'));
    const sales = books.flatMap(b => units[b.id].map((n, i) =>
      ({ month: months[i + months.length - units[b.id].length], book: b.id, units: n })));
    const ratings = [[4, 8, 20, 87, 293], [6, 10, 22, 95, 147], [2, 3, 8, 45, 342], [2, 3, 8, 24, 28],
      [4, 8, 20, 90, 228], [4, 7, 20, 82, 147]].map((counts, i) => ({ book: i + 1, counts }));  // 1 to 5 stars
    const regions = [{ id: '826', name: 'United Kingdom', units: 1480 }, { id: '276', name: 'Germany', units: 1120 },
      { id: '250', name: 'France', units: 760 }, { id: '372', name: 'Ireland', units: 540 },
      { id: '528', name: 'Netherlands', units: 410 }, { id: '724', name: 'Spain', units: 320 }];
    const links = [[5, 1, 38], [1, 3, 24], [2, 5, 18], [3, 6, 21], [4, 1, 12], [2, 6, 9], [4, 3, 15], [5, 6, 14]]
      .map(([source, target, value]) => ({ source, target, value }));   // customers who bought both
    return { books, sales, ratings, regions, links };
  })();
  // prepareSales(raw): parsed dates, sales linked to book objects, revenue and totals
  function prepareSales(raw) {
    const parse = d3.utcParse('%Y-%m'), books = raw.books.map(b => ({ ...b }));
    const byId = d3.index(books, b => b.id);
    const sales = raw.sales.map(s => ({ date: parse(s.month), book: byId.get(s.book),
      units: s.units, revenue: s.units * byId.get(s.book).price }));
    for (const [book, rows] of d3.group(sales, s => s.book)) {
      book.units = d3.sum(rows, s => s.units);
      book.revenue = d3.sum(rows, s => s.revenue);
    }
    const months = d3.rollups(sales, v => ({ units: d3.sum(v, s => s.units),
      revenue: d3.sum(v, s => s.revenue) }), s => +s.date).map(([t, v]) => ({ date: new Date(t), ...v }))
      .sort((a, b) => a.date - b.date);
    const ratings = raw.ratings.map(r => ({ ...r, book: byId.get(r.book) }));
    return { ...raw, books, sales, months, ratings };
  }

  const rows = prepareSales(SALES).ratings.map(r => ({ ...r.book, n: d3.sum(r.counts),
    mean: d3.sum(r.counts, (c, i) => c * (i + 1)) / d3.sum(r.counts) })).sort((a, b) => b.mean - a.mean);
  const y = d3.scaleBand(rows.map(r => r.title), [4, 146]).padding(0.2), x = d3.scaleLinear([0, 5], [170, 500]);
  const svg = d3.select('svg').attr('role', 'group').attr('aria-labelledby', 'chart-title chart-summary');
  // Marks appended in reading order, since focus follows the DOM
  const bars = svg.append('g').attr('role', 'list').selectAll('g').data(rows).join('g').attr('role', 'listitem')
    .attr('aria-label', r => `${r.title}: ${r.mean.toFixed(2)} stars from ${r.n} reviews`)
    .attr('transform', r => `translate(0,${y(r.title)})`);
  bars.append('rect').attr('x', x(0)).attr('width', r => x(r.mean) - x(0)).attr('height', y.bandwidth()).attr('fill', r => r.color);
  bars.append('text').attr('x', x(0) - 6).attr('y', y.bandwidth() / 2).attr('dy', '0.35em').attr('text-anchor', 'end')
    .attr('aria-hidden', 'true').text(r => r.title);

  const nodes = bars.nodes();
  bars.attr('tabindex', (d, i) => i === 0 ? 0 : -1)           // only the current bar is a tab stop
    .on('focus', event => d3.select('#live').text(event.currentTarget.getAttribute('aria-label')))
    .on('keydown', event => {
      const i = nodes.indexOf(event.currentTarget), last = nodes.length - 1;
      const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: last }[event.key];
      if (next === undefined) return;                          // let Tab and other keys through
      event.preventDefault();                                  // no page scrolling
      const target = nodes[Math.max(0, Math.min(last, next))];
      nodes.forEach(n => n.setAttribute('tabindex', n === target ? 0 : -1));
      target.focus();
    });
  // Simulate: focus the first bar, then press ArrowDown twice
  nodes[0].focus({ preventScroll: true });
  for (const key of ['ArrowDown', 'ArrowDown'])
    document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
</script>