独家
Lock-on <mv-lock-on>
面向 Tab 顺序毫无用处的二维布局的空间键盘导航(大小不一的仪表盘小部件、卡片网格、看板、座位图、带标记的地图、节点编辑器、电视和游戏机界面),并提供锁定功能,让移动目标始终保持在视野中。
文化出处
塞尔达传说:时之笛,Nintendo EAD(制作人:宫本茂)(1998 年,游戏)。Z 注目让玩家按一个按钮即可锁定最近的敌人:战斗中镜头始终将其保持在中央,再按一次则切换到下一个目标而不会解除锁定。在 UI 中,方向键会把焦点跳到二维布局中最近的项目,锁定键会固定当前聚焦的项目,使其滚动容器在实时内容移动时始终让它居中,按 Tab 则在附近的其他项目之间循环切换锁定。
工作原理
用于二维布局的空间键盘导航,适用于 Tab 顺序毫无用处的场景(小组件大小不一的仪表盘、卡片网格、看板、座位图、带标记的地图、节点编辑器、电视和游戏机界面),并带有一个锁定功能,让移动中的目标始终保持在视野内。在区域内,方向键会把真实的 DOM 焦点移到该方向上几何距离最近的目标:沿轴向的间距加上一个可调的横向偏移惩罚,因此不规则的网格和空缺(已售座位、空单元格)的表现符合视觉预期;可选择环绕到对侧边缘上对齐最好的目标,支持逐元素的 data-lock-on="skip",以及 data-lock-on-group 容器(看板的一列、一个队列),让方向键在到达其边缘前留在容器内。一个由四个角括号组成的装饰性准星在目标之间滑动(减少动态效果时直接跳转),并在边缘阻挡移动时轻轻一顿。锁定键(L 或 Shift+Space)会钉住获得焦点的目标:每当内容移动时(重新排序的实时列表、在前面插入内容的信息流、重新渲染的地图),它所在的滚动容器或页面都会平滑地把它移回中心,直到你手动滚动或按 Escape。锁定期间方向键仍可自由移动焦点(焦点用较浅的准星,锁定用角括号 + 光晕 + “Locked”标签),一旦你在区域内锁定或移动过,从锁定目标按 Tab / Shift+Tab 会把锁定按距离向外依次移到其他目标,然后把锁定送回起点,并在最后一个之后离开区域。它绝不会在文本字段、select、滑块或自行处理方向键的复合控件(listbox、grid、tablist、menu…)中劫持方向键,可以用 roving 把区域变成单个 Tab 停靠点,并通过可取消的 mv-lock-move 报告每次移动。
| 分类 | 导航 |
|---|---|
| 类型 | Web Component(<mv-lock-on>) |
| 状态 | 稳定版 |
| 套件 | 高级用户导航 |
| 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 theirs, 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
安装
node scripts/add.mjs lock-on --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["lock-on"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/lock-on/lock-on.js, components/lock-on/lock-on.css。
用法
快速开始,最小可运行标记:
<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>标准标记,可在此基础上通过属性、data-* 和 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 }
}
@media (forced-colors: active) {
#lo-demo .lo-chart i, #lo-demo .lo-meter i { forced-color-adjust:none; background:CanvasText }
#lo-demo .lo-chart i:last-child { background:Highlight }
#lo-demo .lo-meter { border:1px solid CanvasText }
#lo-demo .lo-seat[aria-pressed="true"], #lo-demo .lo-legend i[data-k="sel"] { forced-color-adjust:none; background:Highlight; border-color:Highlight; color:HighlightText }
#lo-demo .lo-seat:disabled, #lo-demo .lo-legend i[data-k="taken"] { forced-color-adjust:none; background:Canvas linear-gradient(45deg, transparent 42%, GrayText 42% 58%, transparent 58%); border:1px dashed GrayText; color:transparent }
}
</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>Harborline 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 }));
const timer = setInterval(() => {
if (!ops.isConnected) { clearInterval(timer); return; }
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>API
Attributes
| Name | 类型 | 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 | 类型 | 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). When the locked target itself has keyboard focus, the lock's hairline ring turns into a solid 2px focus ring, so focus stays visible there too. 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 the user has locked or moved with the keyboard during this visit (sequential Tab navigation through the page that lands on a locked target passes straight through); the walk visits each target once, then the next Tab puts the lock back where the walk started and leaves the region (Shift+Tab from the start leaves it backward), and Escape releases the lock at any time, 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.