Exclusifbêta
Lock-on <mv-lock-on>
Navigation spatiale au clavier pour les mises en page 2D où l’ordre de tabulation ne sert à rien (tableaux de bord aux widgets inégaux, grilles de cartes, tableaux kanban, plans de salle, cartes avec marqueurs, éditeurs de nœuds, interfaces TV et console), avec un verrouillage qui garde une cible mobile en vue. Dans la zone, les touches fléchées déplacent le vrai focus du DOM vers la cible géométriquement la plus proche dans cette direction : écart le long de l’axe plus une pénalité réglable pour le décalage latéral, si bien que les grilles irrégulières et les trous (sièges pris, cellules vides) se comportent comme l’œil s’y attend ; retour optionnel à la cible la mieux alignée sur le bord opposé, data-lock-on="skip" par élément, et conteneurs data-lock-on-group (une colonne de kanban, une file) qui gardent les flèches à l’intérieur jusqu’à leur bord. Un réticule décoratif de quatre crochets glisse de cible en cible (un saut en mouvement réduit) et tressaille quand un bord bloque le déplacement. La touche de verrouillage (L ou Maj+Espace) épingle la cible active : son conteneur défilant, ou la page, la ramène en douceur au centre chaque fois que le contenu bouge (listes en direct qui se retrient, flux qui ajoutent en tête, cartes qui se redessinent) jusqu’à ce que vous fassiez défiler à la main ou appuyiez sur Échap. Les flèches continuent de déplacer librement le focus pendant le verrouillage (un réticule plus léger pour le focus, crochets + halo + étiquette « Locked » pour le verrouillage), et depuis la cible verrouillée, Tab / Maj+Tab font passer le verrouillage vers l’extérieur, de cible en cible par ordre de distance. Il ne détourne jamais les flèches dans les champs de texte, les select, les curseurs ou les widgets composites qui les gèrent eux-mêmes (listbox, grid, tablist, menu…), peut faire de la zone un seul arrêt de tabulation avec roving, et signale chaque déplacement via un mv-lock-move annulable.
| Catégorie | Navigation |
|---|---|
| Type | Web Component (<mv-lock-on>) |
| Statut | bêta |
| Kit | Navigation pour utilisateurs avancés |
| Keywords | exclusive, culture, spatial-navigation, keyboard, arrow-keys, focus, roving-tabindex, dashboard, grid, seat-map, kanban, tv, gamepad, lock, follow, live-list, reticle, a11y |
When to use
- A dashboard, card grid or board is laid out in 2D and keyboard users must reach the item next to the one they are on, not the next in DOM order
- A seat map, floor plan or grid with holes needs arrow keys that skip unavailable spots and land on the nearest free one
- Someone watches one row of a live list, feed or map that keeps re-sorting and must keep it in view without chasing it
- A TV, kiosk or gamepad-driven UI needs directional focus movement with a clear focus indicator
Avoid when
- The items form a single row or column, like tabs or a toolbar: a linear roving focus is simpler and expected → use Toggle instead
- The content is tabular data with rows and columns, which needs grid semantics and cell navigation → use Data table instead
- Cards must be moved between columns with the keyboard, not just focused → use Kanban instead
Installation
node scripts/add.mjs lock-on --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["lock-on"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/lock-on/lock-on.js, components/lock-on/lock-on.css.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-lock-on label="Dashboard">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem">
<button>Revenue</button><button>Churn</button><button>Signups</button><button>Tickets</button><button>NPS</button>
</div>
</mv-lock-on>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<div id="lo-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#lo-demo { display:grid; gap:1.25rem; align-content:start; font-size:.875rem }
#lo-demo .lo-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#lo-demo .lo-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#lo-demo .lo-bar h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#lo-demo .lo-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#lo-demo .lo-tools { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
#lo-demo .mv-choice { font-size:.8125rem }
#lo-demo .lo-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
#lo-demo .lo-keys { display:flex; align-items:center; gap:.375rem 1rem; flex-wrap:wrap; margin:0; padding:0; list-style:none; color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-keys li { display:inline-flex; align-items:center; gap:.3125rem }
#lo-demo .lo-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); min-height:1.05rem }
/* Dashboard */
#lo-demo #lo-ops { padding:1.125rem }
#lo-demo .lo-layout { display:grid; grid-template-columns:minmax(0,1fr) 17.5rem; gap:1rem; align-items:stretch }
#lo-demo .lo-dash { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); grid-auto-rows:minmax(6.25rem,auto); gap:.875rem }
#lo-demo .lo-t { position:relative; min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface); transition:border-color var(--mv-duration-fast), background-color var(--mv-duration-fast) }
#lo-demo .lo-w { display:flex; flex-direction:column; gap:.375rem; padding:.875rem 1rem }
#lo-demo .lo-t:hover { border-color:var(--mv-border-strong) }
#lo-demo .lo-t[data-locked] { border-color:color-mix(in oklab, var(--mv-accent) 45%, var(--mv-border)); background:color-mix(in oklab, var(--mv-accent) 4%, var(--mv-surface)) }
#lo-demo .lo-w h4 { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:550 }
#lo-demo .lo-big { font-size:1.625rem; font-weight:650; letter-spacing:-.025em; font-variant-numeric:tabular-nums; line-height:1.1 }
#lo-demo .lo-delta { color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-delta b { color:var(--mv-success); font-weight:600 }
#lo-demo .lo-delta b[data-bad] { color:var(--mv-danger) }
#lo-demo .lo-span2 { grid-column:span 2 }
#lo-demo .lo-tall { grid-row:span 2 }
#lo-demo .lo-chart { flex:1; display:flex; align-items:flex-end; gap:5px; min-height:6.5rem; padding-top:.5rem }
#lo-demo .lo-chart i { flex:1; height:var(--v); border-radius:3px 3px 1px 1px; background:color-mix(in oklab, var(--mv-accent) 30%, var(--mv-bg-muted)) }
#lo-demo .lo-chart i:last-child { background:var(--mv-accent) }
#lo-demo .lo-axis { display:flex; justify-content:space-between; color:var(--mv-fg-subtle); font-size:.6875rem }
#lo-demo .lo-rows { display:grid; gap:.4375rem; margin:.125rem 0 0; padding:0; list-style:none; font-size:.8125rem }
#lo-demo .lo-rows li { display:grid; grid-template-columns:5.5rem minmax(0,1fr) 2.25rem; align-items:center; gap:.625rem }
#lo-demo .lo-rows li span:last-child { text-align:right; color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#lo-demo .lo-meter { height:6px; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
#lo-demo .lo-meter i { display:block; width:var(--v); height:100%; border-radius:inherit; background:var(--mv-accent) }
#lo-demo .lo-list { display:grid; gap:.3125rem; margin:0; padding:0; list-style:none; font-size:.8125rem }
#lo-demo .lo-list li { display:flex; justify-content:space-between; gap:.5rem }
#lo-demo .lo-list li span:last-child { color:var(--mv-danger); font-variant-numeric:tabular-nums; font-weight:550 }
#lo-demo .lo-faces { display:flex; margin-top:.25rem }
#lo-demo .lo-faces span { display:grid; place-items:center; width:1.75rem; height:1.75rem; margin-inline-start:-.375rem; border:2px solid var(--mv-surface); border-radius:50%; background:var(--mv-bg-emphasis); font-size:.625rem; font-weight:650 }
#lo-demo .lo-faces span:first-child { margin:0 }
/* Live queue: one group, so up/down stay inside it until its edge */
#lo-demo .lo-queue-wrap { display:flex; flex-direction:column; min-height:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#lo-demo .lo-queue-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; padding:.75rem .875rem .625rem; border-bottom:1px solid var(--mv-border) }
#lo-demo .lo-queue-head h4 { margin:0; font-size:.8125rem }
#lo-demo .lo-queue { position:relative; flex:1 1 0; min-height:0; height:22rem; overflow:auto; overflow-anchor:none; margin:0; padding:.625rem; list-style:none; display:grid; align-content:start; gap:.5rem; scrollbar-width:thin }
#lo-demo .lo-tk { display:grid; gap:.1875rem; padding:.5625rem .6875rem; font-size:.8125rem }
#lo-demo .lo-tk-top { display:flex; align-items:center; gap:.4375rem; color:var(--mv-fg-muted); font-size:.6875rem }
#lo-demo .lo-tk-top code { font:600 .6875rem var(--mv-font-mono); color:var(--mv-fg) }
#lo-demo .lo-tk-top time { margin-inline-start:auto; font-variant-numeric:tabular-nums }
#lo-demo .lo-dot { width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-fg-subtle) }
#lo-demo .lo-dot[data-p="urgent"] { background:var(--mv-danger) }
#lo-demo .lo-dot[data-p="high"] { background:var(--mv-warning) }
#lo-demo .lo-tk b { font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#lo-demo .lo-tk-meta { display:flex; align-items:center; justify-content:space-between; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-tk[data-new] { animation:lo-in .5s var(--mv-ease-out) both }
@keyframes lo-in { from { opacity:0; translate:0 -6px } }
/* Seat map */
#lo-demo .lo-seat-body { display:grid; grid-template-columns:minmax(0,1fr) 15rem; gap:1.25rem; padding:1.125rem; align-items:center }
#lo-demo .lo-plane { overflow-x:auto; padding:.75rem .5rem .5rem }
#lo-demo #lo-seats { width:max-content; margin-inline:auto }
#lo-demo .lo-cabin { display:grid; grid-template-columns:1.25rem repeat(15,1.875rem); grid-template-rows:1rem repeat(3,1.875rem) 1.375rem repeat(3,1.875rem); gap:.3125rem; align-items:center; justify-items:center }
#lo-demo .lo-cabin > span { color:var(--mv-fg-subtle); font-size:.6875rem; font-variant-numeric:tabular-nums }
#lo-demo .lo-seat { width:1.875rem; height:1.875rem; padding:0; border:1px solid var(--mv-border-strong); border-radius:.4375rem .4375rem .3125rem .3125rem; background:var(--mv-surface); color:var(--mv-fg-muted); font:600 .625rem var(--mv-font-sans); cursor:pointer; transition:background-color var(--mv-duration-fast), border-color var(--mv-duration-fast), color var(--mv-duration-fast) }
#lo-demo .lo-seat:hover:not(:disabled) { border-color:var(--mv-accent) }
#lo-demo .lo-seat[data-extra] { background:color-mix(in oklab, var(--mv-info) 12%, var(--mv-surface)); border-color:color-mix(in oklab, var(--mv-info) 45%, var(--mv-border)) }
#lo-demo .lo-seat[aria-pressed="true"] { background:var(--mv-accent); border-color:var(--mv-accent); color:var(--mv-fg-on-accent) }
#lo-demo .lo-seat:disabled { background:var(--mv-bg-muted); border-color:transparent; color:transparent; cursor:not-allowed; background-image:linear-gradient(45deg, transparent 45%, var(--mv-border-strong) 45% 55%, transparent 55%) }
#lo-demo .lo-legend { display:flex; gap:.5rem 1rem; flex-wrap:wrap; margin:.875rem 0 0; padding:0; list-style:none; justify-content:center; color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-legend li { display:inline-flex; align-items:center; gap:.375rem }
#lo-demo .lo-legend i { width:.875rem; height:.875rem; border-radius:.25rem; border:1px solid var(--mv-border-strong); background:var(--mv-surface) }
#lo-demo .lo-legend i[data-k="extra"] { background:color-mix(in oklab, var(--mv-info) 12%, var(--mv-surface)); border-color:color-mix(in oklab, var(--mv-info) 45%, var(--mv-border)) }
#lo-demo .lo-legend i[data-k="sel"] { background:var(--mv-accent); border-color:var(--mv-accent) }
#lo-demo .lo-legend i[data-k="taken"] { background:var(--mv-bg-muted) linear-gradient(45deg, transparent 42%, var(--mv-border-strong) 42% 58%, transparent 58%); border-color:transparent }
#lo-demo .lo-sum { display:grid; gap:.625rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#lo-demo .lo-sum h4 { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:550 }
#lo-demo .lo-sum-seats { min-height:1.5rem; font-size:1rem; font-weight:600 }
#lo-demo .lo-sum-seats[data-empty] { color:var(--mv-fg-subtle); font-weight:500; font-size:.875rem }
#lo-demo .lo-sum-total { display:flex; justify-content:space-between; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#lo-demo .lo-sum-total b { color:var(--mv-fg) }
@media (max-width:60rem) {
#lo-demo .lo-layout, #lo-demo .lo-seat-body { grid-template-columns:minmax(0,1fr) }
#lo-demo .lo-queue { flex:none; height:18rem }
}
@media (max-width:36rem) {
#lo-demo .lo-dash { grid-template-columns:minmax(0,1fr) }
#lo-demo .lo-span2 { grid-column:auto }
}
</style>
<!-- 1. Dashboard + live queue: arrows move spatially, the queue ticket stays locked and centered as tickets stream in -->
<section class="lo-app" aria-label="Support operations">
<header class="lo-bar">
<div>
<h3>Support operations</h3>
<p>Northwind Help Desk · Today, Oct 15, 2026</p>
</div>
<div class="lo-tools">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="lo-live" checked> Live queue</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="lo-wrap" checked> Wrap at edges</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="lo-lock-urgent">Lock oldest urgent</button>
</div>
</header>
<mv-lock-on id="lo-ops" targets=".lo-t" label="Support dashboard" wrap>
<div class="lo-layout">
<div class="lo-dash">
<article class="lo-t lo-w" aria-labelledby="lo-h1">
<h4 id="lo-h1">Open tickets</h4>
<span class="lo-big">1,284</span>
<span class="lo-delta"><b data-bad>+4.2%</b> vs last week</span>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h2">
<h4 id="lo-h2">First response</h4>
<span class="lo-big">1h 12m</span>
<span class="lo-delta"><b>−8 min</b> vs last week</span>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h3">
<h4 id="lo-h3">Satisfaction</h4>
<span class="lo-big">94.1%</span>
<span class="lo-delta"><b>+1.3 pts</b> · 612 ratings</span>
</article>
<article class="lo-t lo-w lo-span2 lo-tall" aria-labelledby="lo-h4">
<h4 id="lo-h4">Ticket volume · last 14 days</h4>
<div class="lo-chart" aria-hidden="true">
<i style="--v:48%"></i><i style="--v:55%"></i><i style="--v:51%"></i><i style="--v:62%"></i><i style="--v:70%"></i><i style="--v:38%"></i><i style="--v:33%"></i>
<i style="--v:58%"></i><i style="--v:64%"></i><i style="--v:61%"></i><i style="--v:73%"></i><i style="--v:80%"></i><i style="--v:44%"></i><i style="--v:92%"></i>
</div>
<div class="lo-axis" aria-hidden="true"><span>Oct 2</span><span>Oct 8</span><span>Today · 412</span></div>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h5">
<h4 id="lo-h5">SLA at risk</h4>
<ul class="lo-list">
<li><span>#48188 · Billing</span><span>12 min</span></li>
<li><span>#48191 · Login</span><span>26 min</span></li>
<li><span>#48196 · Shipping</span><span>41 min</span></li>
</ul>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h6">
<h4 id="lo-h6">Agents online</h4>
<span class="lo-big">38<span style="color:var(--mv-fg-subtle);font-size:1rem;font-weight:500"> / 52</span></span>
<span class="lo-faces" aria-hidden="true"><span>AO</span><span>LB</span><span>MT</span><span>SA</span><span>+34</span></span>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h7">
<h4 id="lo-h7">Top issues</h4>
<ul class="lo-list" style="--mv-danger:var(--mv-fg-muted)">
<li><span>Refunds</span><span>31%</span></li>
<li><span>Sign-in</span><span>22%</span></li>
<li><span>Shipping</span><span>18%</span></li>
</ul>
</article>
<article class="lo-t lo-w lo-span2" aria-labelledby="lo-h8">
<h4 id="lo-h8">Backlog by region</h4>
<ul class="lo-rows">
<li><span>Americas</span><span class="lo-meter"><i style="--v:72%"></i></span><span>512</span></li>
<li><span>EMEA</span><span class="lo-meter"><i style="--v:54%"></i></span><span>386</span></li>
<li><span>Asia Pacific</span><span class="lo-meter"><i style="--v:41%"></i></span><span>294</span></li>
</ul>
</article>
</div>
<aside class="lo-queue-wrap" data-lock-on-group aria-labelledby="lo-qh">
<div class="lo-queue-head">
<h4 id="lo-qh">Live queue</h4>
<span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot" data-pulse></span>Live</span>
</div>
<ul class="lo-queue" id="lo-queue" aria-label="Incoming tickets"></ul>
</aside>
</div>
</mv-lock-on>
<div class="lo-foot">
<ul class="lo-keys" aria-label="Keyboard">
<li><span class="mv-sr-only">Arrow keys:</span><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:270deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:0deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:90deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:180deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd> Nearest item</li>
<li><kbd class="mv-kbd" data-size="sm">L</kbd> Lock</li>
<li><kbd class="mv-kbd" data-size="sm">Tab</kbd> Move the lock to the next nearest</li>
<li><kbd class="mv-kbd" data-size="sm">Esc</kbd> Release</li>
</ul>
<p class="lo-log" id="lo-log" aria-live="off">Locked on a ticket: it stays centered as new ones arrive.</p>
</div>
</section>
<!-- 2. Seat map: one Tab stop (roving), taken seats are skipped geometrically, arrows wrap -->
<section class="lo-app" aria-label="Seat selection">
<header class="lo-bar">
<div>
<h3>Choose your seats</h3>
<p>Flight 318 · Zurich (ZRH) to New York (JFK) · Thu, Oct 15, 2026</p>
</div>
<span class="mv-badge" data-variant="secondary">Economy · 2 travelers</span>
</header>
<div class="lo-seat-body">
<div>
<div class="lo-plane">
<mv-lock-on id="lo-seats" targets=".lo-seat" label="Seat map, rows 10 to 24" roving wrap>
<div class="lo-cabin" id="lo-cabin"></div>
</mv-lock-on>
</div>
<ul class="lo-legend">
<li><i aria-hidden="true"></i>Standard · $29</li>
<li><i data-k="extra" aria-hidden="true"></i>Extra legroom · $65</li>
<li><i data-k="sel" aria-hidden="true"></i>Selected</li>
<li><i data-k="taken" aria-hidden="true"></i>Taken</li>
</ul>
</div>
<div class="lo-sum">
<h4>Your seats</h4>
<div class="lo-sum-seats" id="lo-sum-seats" aria-live="polite">12C, 12D</div>
<div class="lo-sum-total"><span>Seat fees</span><b id="lo-sum-total">$58.00</b></div>
<button type="button" class="mv-button" data-size="sm" style="width:100%">Continue to baggage</button>
</div>
</div>
</section>
<script type="module">
const ops = document.getElementById("lo-ops");
const queue = document.getElementById("lo-queue");
const log = document.getElementById("lo-log");
const say = (t) => { log.textContent = t; };
/* ── Live queue ── */
const people = [["Amara Okafor", "Lagos"], ["Lukas Brandt", "Berlin"], ["Mei Tanaka", "Osaka"], ["Sofia Álvarez", "Madrid"], ["Priya Nair", "Bengaluru"], ["Noah Williams", "Toronto"], ["Chloé Martin", "Lyon"], ["Omar Haddad", "Dubai"], ["Elena Petrova", "Sofia"], ["Diego Ramos", "São Paulo"], ["Hannah Kim", "Seoul"], ["Jack O’Brien", "Dublin"], ["Fatima Zahra", "Casablanca"], ["Liam Chen", "Vancouver"]];
const subjects = ["Refund not received", "Can’t sign in after password reset", "Package marked delivered, not here", "Charged twice for March invoice", "Change shipping address", "CSV export times out", "Two-factor code never arrives", "Upgrade to the annual plan", "Item damaged on arrival", "Questions about API rate limits", "Cancel subscription at renewal", "Invoice needs a VAT number"];
const prio = ["normal", "high", "normal", "urgent", "normal", "high"];
const badge = { urgent: ["danger", "Urgent"], high: ["warning", "High"], normal: ["secondary", "Normal"] };
let seq = 48201;
let tick = 0;
function ticket(i, age, fresh) {
const [name, city] = people[i % people.length];
const subject = subjects[(i * 5) % subjects.length];
const p = prio[i % prio.length];
const id = `#${seq++}`;
const li = document.createElement("li");
li.className = "lo-t lo-tk";
li.dataset.lockOnLabel = `Ticket ${id}, ${subject}`;
li.dataset.priority = p;
if (fresh) li.dataset.new = "";
const top = document.createElement("span");
top.className = "lo-tk-top";
const dot = document.createElement("span");
dot.className = "lo-dot";
dot.dataset.p = p;
const code = document.createElement("code");
code.textContent = id;
const time = document.createElement("time");
time.textContent = age;
top.append(dot, code, time);
const b = document.createElement("b");
b.textContent = subject;
const meta = document.createElement("span");
meta.className = "lo-tk-meta";
const who = document.createElement("span");
who.textContent = `${name} · ${city}`;
const tag = document.createElement("span");
tag.className = "mv-badge";
tag.dataset.size = "sm";
tag.dataset.variant = badge[p][0];
tag.textContent = badge[p][1];
meta.append(who, tag);
li.append(top, b, meta);
return li;
}
// Newest on top: build from the oldest and prepend.
for (let i = 13; i >= 0; i--) queue.prepend(ticket(13 - i, `${(i + 1) * 3} min`));
const initial = queue.children[6];
// Non-empty initial state: a ticket is locked and stays centered in the queue.
requestAnimationFrame(() => ops.lock(initial, { focus: false }));
setInterval(() => {
if (!document.getElementById("lo-live")?.checked || document.hidden) return;
tick++;
for (const t of queue.querySelectorAll("time")) {
const m = t.textContent === "just now" ? 0 : parseInt(t.textContent, 10);
t.textContent = `${m + 1} min`;
}
queue.prepend(ticket(seq, "just now", true));
// Every third tick a ticket gets escalated and moves up the queue (sometimes the locked one).
if (tick % 3 === 0) {
const items = [...queue.children];
const pick = tick % 6 === 0 && ops.lockedTarget ? ops.lockedTarget : items[Math.min(items.length - 1, 8 + (tick % 4))];
if (pick && pick !== items[1]) queue.insertBefore(pick, items[1]);
}
// Cap the list, never dropping the locked ticket.
while (queue.children.length > 18) {
const last = [...queue.children].reverse().find((li) => li !== ops.lockedTarget);
last?.remove();
}
}, 2400);
document.getElementById("lo-wrap").addEventListener("change", (e) => { ops.wrap = e.target.checked; });
document.getElementById("lo-lock-urgent").addEventListener("click", () => {
const urgent = [...queue.querySelectorAll('[data-priority="urgent"]')].at(-1);
if (urgent) ops.lock(urgent);
});
const arrows = { next: "Tab", previous: "Shift+Tab", first: "Home", last: "End" };
const name = (el) => el?.dataset.lockOnLabel ?? el?.querySelector("h4")?.textContent ?? "";
ops.addEventListener("mv-lock-move", (e) => say(`mv-lock-move · ${arrows[e.detail.direction] ?? e.detail.direction}${e.detail.wrapped ? ", wrapped" : ""} → ${name(e.detail.to)}`));
ops.addEventListener("mv-lock", (e) => say(e.detail.locked ? `mv-lock · locked on ${name(e.detail.target)}` : `mv-lock · released (${e.detail.reason})`));
ops.addEventListener("mv-lock-edge", (e) => say(`mv-lock-edge · nothing ${e.detail.direction === "up" ? "above" : e.detail.direction === "down" ? "below" : `to the ${e.detail.direction}`}`));
/* ── Seat map ── */
const cabin = document.getElementById("lo-cabin");
const letters = ["F", "E", "D", null, "C", "B", "A"];
const taken = new Set(["10A", "10B", "11F", "13C", "13D", "13E", "14A", "15B", "15C", "16E", "17A", "17F", "18B", "18C", "18D", "19F", "20A", "20B", "21D", "22C", "22E", "23A", "23F", "24B", "24C"]);
const selected = new Set(["12C", "12D"]);
const rows = Array.from({ length: 15 }, (_, i) => 10 + i);
const place = (el, col, row) => { el.style.gridColumn = String(col); el.style.gridRow = String(row); cabin.append(el); };
rows.forEach((n, i) => {
const label = document.createElement("span");
label.textContent = n;
label.setAttribute("aria-hidden", "true");
place(label, i + 2, 1);
});
letters.forEach((l, r) => {
if (!l) return; // the aisle
const lab = document.createElement("span");
lab.textContent = l;
lab.setAttribute("aria-hidden", "true");
place(lab, 1, r + 2);
rows.forEach((n, i) => {
const id = `${n}${l}`;
const extra = n <= 11 || n === 16;
const b = document.createElement("button");
b.type = "button";
b.className = "lo-seat";
b.textContent = l;
if (extra) b.dataset.extra = "";
const pos = l === "A" || l === "F" ? "window" : l === "C" || l === "D" ? "aisle" : "middle";
if (taken.has(id)) {
b.disabled = true;
b.setAttribute("aria-label", `Seat ${id}, taken`);
} else {
b.setAttribute("aria-label", `Seat ${id}, ${pos}${extra ? ", extra legroom" : ""}, ${extra ? "$65" : "$29"}`);
b.setAttribute("aria-pressed", String(selected.has(id)));
b.dataset.seat = id;
b.dataset.price = extra ? "65" : "29";
}
place(b, i + 2, r + 2);
});
});
const sumSeats = document.getElementById("lo-sum-seats");
const sumTotal = document.getElementById("lo-sum-total");
const refresh = () => {
const list = [...cabin.querySelectorAll('.lo-seat[aria-pressed="true"]')];
const ids = list.map((b) => b.dataset.seat).sort((a, b) => parseInt(a, 10) - parseInt(b, 10) || a.localeCompare(b));
sumSeats.textContent = ids.length ? ids.join(", ") : "No seats selected";
sumSeats.toggleAttribute("data-empty", !list.length);
const total = list.reduce((s, b) => s + Number(b.dataset.price), 0);
sumTotal.textContent = total.toLocaleString("en-US", { style: "currency", currency: "USD" });
};
cabin.addEventListener("click", (e) => {
const b = e.target.closest(".lo-seat");
if (!b || b.disabled) return;
b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"));
refresh();
});
refresh();
// The seat map's single Tab stop starts on the first selected seat.
requestAnimationFrame(() => document.getElementById("lo-seats").moveTo('[data-seat="12C"]', { focus: false }));
</script>
</div>Référence culturelle
The Legend of Zelda: Ocarina of Time, Nintendo EAD (produit par Shigeru Miyamoto) (1998, jeu vidéo). La visée Z permet au joueur de verrouiller d’une seule touche l’ennemi le plus proche : la caméra le garde au centre pendant que le combat se déplace, et une nouvelle pression passe à la cible suivante sans rompre le verrouillage. Dans l’interface, les touches fléchées font sauter le focus vers l’élément le plus proche dans une mise en page 2D, et une touche de verrouillage épingle l’élément actif pour que son conteneur défilant le garde centré pendant que le contenu en direct bouge, Tab faisant passer le verrouillage aux autres éléments les plus proches.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
targets | CSS selector | a[href], button, input, select, textarea, summary, [tabindex], [contenteditable] | Elements that arrows move between. Disabled ones (:disabled, aria-disabled="true", inside [inert]), invisible ones, anything inside data-lock-on="skip" and targets of a nested mv-lock-on are left out. With the default selector an app-set tabindex="-1" also means “not a stop”. Matched elements that are not focusable get tabindex="0" (restored when the element is removed). |
wrap | boolean | false | At an edge, arrows continue to the best-aligned target on the opposite edge (same row or column first). Without it, the reticle nudges toward the blocked side and mv-lock-edge fires. |
roving | boolean | false | The region becomes a single Tab stop: only the current target keeps tabindex="0", the others get "-1" (original values restored on removal). Tab enters on the last target used. |
lock-keys | comma-separated key combos | l, shift+space | Keys that lock the focused target (pressed on another target, the lock moves there; on the locked one, it releases). Modifiers: shift, alt, ctrl, meta. Empty disables the key; lock() still works. The region description lists them. |
follow | center | nearest | none | center | While locked, how the nearest scroll container of the target (per axis, or the page) keeps it in view as content moves: eased back to the center, only back inside the view with a small margin, or not at all. A target taller than the view is aligned to its start. |
cross-weight | number | 2 | Penalty per pixel of lateral offset in the distance score (score = gap along the axis + cross-weight × lateral gap + a small pull toward aligned centers). Raise it to favor staying in the same row or column, lower it to favor the closest target overall. |
reticle | on | compact | off | on | compact hides the “Locked” tag; off draws nothing and keeps the browser focus ring (navigation and lock still work). |
label | string | Accessible name of the region (sets aria-label). The region gets role="group" unless it already has a role. | |
data-lock-on | "skip" | "keys" | On an element inside the region: skip = never a target (nor anything inside it); keys = this subtree owns the arrow keys (a custom widget), the component leaves them alone there. | |
data-lock-on-group | boolean | On a container (a kanban column, a queue, a sidebar): arrows first look for a target inside the same group and only leave it at its edge. Home / End go to the first / last target of the group (Ctrl+Home / Ctrl+End: of the region). | |
data-lock-on-label | string | On a target: name used in announcements (default: aria-label, aria-labelledby, then its first heading or text). | |
data-locked / data-lock-current | set by the component | data-locked is set on the locked target and on the region while a lock is held; data-lock-current on the target the reticle frames. Style hooks, removed afterwards. |
Properties
| Name | Type | Description |
|---|---|---|
current | Element | null | Target that holds, or last held, focus in the region (read-only). |
locked | boolean | True while a target is locked (read-only; use lock() / unlock()). |
lockedTarget | Element | null | The locked target (read-only). |
targetList | Element[] | Every current target in reading order (read-only). |
strings | Partial<Record<string, string>> | Overrides for the region description and announcements (keys: describe, describeLock ({keys}), or, locked ({label}), released, lost ({label}), tag). English defaults. |
targets / wrap / roving / lockKeys / follow / crossWeight / reticle / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
move(direction) | Moves like an arrow key ("up" | "down" | "left" | "right"), from the current target or the first one; for gamepads, remotes and voice commands. Returns the new target, or null if nothing was found or mv-lock-move was canceled. |
nearest(direction, from?) | The target an arrow would reach from from (default: current), without moving. Useful for hints and tests. |
moveTo(target, { focus = true }) | Focuses a target (element or selector) and shows the reticle as if reached with the keyboard. focus: false only makes it the current target (the Tab stop with roving). |
lock(target = current, { focus = true }) | Locks a target and announces it; focus: false locks without moving focus (a row picked from elsewhere in the app). Returns false if the target is not in the region or mv-lock was canceled. |
unlock() / toggleLock() | Releases or toggles the lock. |
refresh() | Re-scans the targets after changes the internal MutationObserver cannot see (a shadow root, a canvas overlay repositioned by CSS only). |
Events
| Name | Description |
|---|---|
mv-lock-move | Cancelable, before focus moves. detail: { from, to, direction: "up" | "down" | "left" | "right" | "next" | "previous" | "first" | "last" | "none", wrapped, reason: "arrow" | "cycle" | "key" | "api" }. preventDefault() keeps focus where it is (a locked editor, an unsaved card). |
mv-lock | The lock was taken or released. detail: { locked, target, reason: "key" | "api" | "escape" | "scroll" | "removed" }. Cancelable except for removed (the target left the DOM; focus then moves to the nearest target if it was on the removed one). |
mv-lock-edge | An arrow found nothing in that direction (and wrap is off). detail: { from, direction }. Move focus to a neighboring region or load the next page here. |
Content structure
| Name | Description |
|---|---|
(content) | Any 2D layout. The component appends an aria-hidden overlay (.mv-lock-on-layer) and two visually hidden elements (description and live region); it only touches the app's DOM through tabindex (roving or non-focusable targets) and the data-locked / data-lock-current hooks. |
CSS classes
| Name | Description |
|---|---|
mv-lock-on-layer | Overlay (aria-hidden, pointer-events: none) holding the reticle, positioned over the region. |
mv-lock-on-reticle | Two reticles: data-kind="focus" follows keyboard focus (hidden when focus sits on the locked target), data-kind="lock" frames the locked target. data-active while shown, data-offscreen when the target is scrolled out of its container. Each holds .mv-lock-on-frame with four .mv-lock-on-corner[data-corner]; the lock one adds .mv-lock-on-ring (halo) and .mv-lock-on-tag (lock icon + “Locked”). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-lock-on-color | var(--mv-accent) | Brackets, ring and tag color. |
--mv-lock-on-tag-fg | var(--mv-fg-on-accent) | Text color of the “Locked” tag. |
--mv-lock-on-gap | 6px | Space between the target and the brackets (the brackets’ radius follows the target’s corners plus this gap). |
--mv-lock-on-locked-gap | 4px | Tighter gap while locked (the brackets close in). |
--mv-lock-on-corner | 14px | Bracket length (capped at 40% of the target for small ones such as seats). |
Accessibility
Focus is always real DOM focus, so screen readers announce each target exactly as they would on Tab; the reticle is decorative (aria-hidden, pointer-events: none) and replaces only the browser's default focus ring on the target it frames, never an app's own focus styles (with reticle="off" the default ring stays). The region gets role="group" with the label attribute as its name, and aria-describedby points to a visually hidden description of the keyboard model (arrows, the lock keys, Tab while locked, Escape), generated from lock-keys and localizable through strings. Arrow keys are handled only when focus is inside the region, without modifiers, and never inside text fields, selects, radio groups, media, contenteditable, [data-lock-on="keys"] or composite widgets that own them (slider, spinbutton, listbox, grid, treegrid, tree, tablist, menu, menubar, radiogroup, combobox, toolbar). Tab is only taken over while focus is on the locked target, and Escape releases the lock (then Tab leaves the region normally), which the description states: no keyboard trap; with roving the region is a single Tab stop that remembers the last target. A polite live region announces lock changes only (“Locked on Revenue chart”, “Lock released”, “Lock lost: … is no longer available”), never ordinary moves, which the focus change already conveys. If the focused target is removed (live list), focus moves to the nearest remaining target instead of falling back to the page. Scrolling by hand (wheel, touch, scrollbar, page keys) releases the lock rather than fighting the user. Reduced motion (OS or data-motion="reduce"): the reticle jumps instead of gliding, the edge nudge and halo pulse are off and the follow scroll is instant. Forced colors: brackets, ring and tag use Highlight, and the framed target gets a Highlight outline. Performance: rects are read on key presses only; the frame loop runs during a glide, briefly after scroll, resize or DOM changes, and while a lock is held (one rect read per frame), and stops when the element is removed.