Navigation and adaptive destinations
One truthful destination model across a compact bar, persistent rail and modal drawer, with actual URL history, retained context and recoverable local requests.
Choose the interaction model
| Intent | Use |
|---|---|
| Go to an addressable activity | Named navigation with actual hrefs and current-route state. Bars, rails and drawers present the same destinations. |
| Switch related views of one task | Tabs with associated panels; view selection is different from route arrival. |
| Run frequent task actions | Toolbar with actual commands and effects. |
| Reveal command categories | Menu/Menubar; a primary destination list does not acquire command-menu roles. |
| Find changing or numerous destinations | Search with real indexed results, loading, empty/error states and recovery. |
| Expose ancestry or a bounded collection | Breadcrumbs/Pagination; neither replaces primary navigation. |
Bar, rail or drawer
Choose a presentation from available inline space, destination count and label length. Keep destination identity, names/order and current-page truth consistent. Material navigation families are coverage references; this is an authored React/web pattern using native navigation and the shared Drawer, with MobTranslate tokens and web link behavior.
| Presentation | Behavior and placement |
|---|---|
| Compact bar | Three explicit primary links with decorative icons and persistent labels. The lab places the bar after its content in DOM order, in document flow. It never covers content or the software keyboard. |
| Persistent rail | The same destinations before the content, along the inline start edge. Its boundary and padding use logical CSS so RTL changes placement without changing link order. |
| Temporary drawer | An explicitly named opener reveals a modal navigation surface. It traps focus and has a title, close/Escape path, destination-arrival focus and defined resize recovery. |
| Larger destination sets | Prefer a drawer/grouped navigation or searchable hierarchy. Do not hide required names behind hover-only tooltips or discard lower-priority destinations to fit a bar. |
| Fixed/sticky product placement | This lab uses flow layout. A fixed bottom bar additionally needs safe-area insets, reserved content space, virtual-keyboard/zoom behavior and reachable content tests; these are consumer responsibilities, not supplied physics. |
Material bar and rail reference choices
The May 2025 expressive reference replaces the baseline navigation bar with a flexible bar, and the baseline rail with collapsed/expanded rails. A bar is for three to five stable primary destinations in compact/medium windows; a rail commonly has three to seven in medium and larger windows. The reference offers both a medium bar and rail depending on space, while some rail resizing text says not to use a bar there. Choose by the task’s available space and keep the destination model consistent rather than treating those statements as a universal pixel breakpoint.
This lab has three real destinations, with no competing persistent navigation copy. Its below-480 drawer, below-720 bar and ≥720 rail thresholds measure the local workspace, not native window classes. The optional compact bar, medium horizontal items and actual standard rail expansion demonstrate alternate presentation choices. A modal expanded rail corresponds here to the shared Drawer navigation surface; there is no native SDK transform or predictive-back integration.
| Reference point | Local implementation |
|---|---|
| Icons, labels and current state | Keep labels visible and concise. Reference active icons are filled or heavier when no filled form exists. These simple SVG outlines become 2.5px versus 1.5px strokes on the actual current destination; high-contrast fill/bold text and aria-current remain separate from keyboard focus. |
| Scaled labels | The bar usage chapter discourages wrapping, but its accessibility chapter explicitly permits it and vertical growth at 2× text. This pattern preserves full labels and reflows columns/targets rather than truncating, shrinking text or hiding names. Rail labels also retain their full meaning. |
| Bar placement and lifetime | A product bar spans the bottom of its window with stable item positions, and must stay clear of FABs, navigation and important targets. This bounded lab places it after content in flow. It does not implement hide-on-scroll, swipe navigation or automatic keyboard/safe-area displacement. A current-destination reselect resets to the heading while retaining draft/settings/bookmarks. |
| Rail placement and expansion | Place a rail on the logical leading side outside task panes, with an optional menu/FAB at its top. The local expansion control changes actual rail width and link arrangement; it is not a FAB or a second destination. A consuming fixed rail must retain visible destinations while content scrolls. This lab’s surrounding documentation still scrolls normally. |
| Targets, roles and keyboard | Reference expanded targets span the full rail width even when the indicator hugs its label. Local anchors fill that width, remain links and use Tab/Enter. Space scrolls and no arrow composite is invented. Moving keyboard focus alone does not navigate, despite ambiguous wording in the reference’s bar usage text. |
| Color, motion and badges | Reference surface-container/secondary roles, 8–10% state layers, ripple and indicator expansion differ from this library’s background/foreground inversion, ring and heavier icons. Optional unread badges need destination ownership and read/reset behavior; see Badge. No native ripple/filled-glyph animation or automatic badge service is claimed. |
Drawer reference, grouping and modality
The expressive reference no longer recommends its navigation drawer component, preferring the expanded navigation rail with largely the same function. This does not deprecate the local shared Drawer primitive for modal support tasks. The baseline drawer guidance remains useful for grouped/hierarchical destinations: standard beside content on larger layouts, modal over a scrim when space is limited, frequent destinations first and optional section labels/dividers between groups, rather than a divider after every item.
Native baseline specs include a 360dp-wide/full-height sheet, 24dp icons and a 56dp/336dp active indicator. The local Drawer uses its separately documented bounded web geometry, not those converted dp values. The small fixture has one group; it does not demonstrate arbitrary account switching or nested hierarchies.
- A modal navigation surface opens from an external named control, blocks background interaction, scrolls independently and closes through destination arrival, Escape, close or the installed outside-interaction policy. Native edge swipes/predictive back are not supplied by this web composition.
- Use one primary destination model at a time. A temporary modal surface may repeat destinations while its background is inert; this differs from two competing persistent navigation bars. The local initial focus is the explanatory title, followed by native links, not an automatic first-item selection.
- The baseline drawer usage text permits label truncation while the navigation accessibility guidance requires full labels at 2× scaling. Local labels grow/wrap and remain available; clear short names, optional consistently used icons and actual target geometry take priority over a forced one-line shape.
- For a full product hierarchy, expose meaningful groups and a current destination, preserve history and useful task context, and ensure the opener survives or has a deliberate replacement after resizing. A navigation drawer is not a command menu or a permission decision.
Working adaptive workspace
Follow Overview, Reading and Saved: the actual demoDestination query selects different local activities. Edit the Overview draft, change Reading spacing and create a local bookmark, then revisit them through links and browser history. These page-owned values survive in-place navigation and resizing; full reload, separate-tab navigation and leaving the page create a separate lifecycle.
The width cap demonstrates available-space decisions rather than simulated device names. The default uses a drawer below 480px, an in-flow bottom bar from 480px to below 720px and a rail from 720px. Use compact destination bar to keep this short three-link bar below 480px. A cap cannot make the containing document wider. Hold local destination requests to inspect retained/loading/failure/cancellation states.
Loading the local adaptive workspace…
'use client';
import { Suspense, useEffect, useRef, useState, type MouseEvent } from 'react';
import { useSearchParams } from 'next/navigation';
import { Button, Drawer, DrawerBackdrop, DrawerClose, DrawerDescription, DrawerPopup, DrawerPortal, DrawerTitle, DrawerTrigger, Input } from '@mobtranslate/ui';
import './adaptive-navigation.css';
const destinations = [
{ id:'overview', label:'Overview', title:'Local workspace overview', icon:<path d="m3 10 9-7 9 7v10H3Z M9 20v-7h6v7" /> },
{ id:'reading', label:'Reading', title:'Local reading settings', icon:<path d="M3 4h7a2 2 0 0 1 2 2v15a3 3 0 0 0-3-2H3Z M21 4h-7a2 2 0 0 0-2 2v15a3 3 0 0 1 3-2h6Z" /> },
{ id:'saved', label:'Saved', title:'Local saved examples', icon:<path d="M6 3h12v18l-6-4-6 4Z" /> },
] as const;
type Destination = typeof destinations[number]['id'];
type Layout = 'drawer' | 'bar' | 'rail';
type Phase = 'idle' | 'held' | 'routing';
function AdaptiveWorkspace() {
const search = useSearchParams();
const raw = search.get('demoDestination');
const matched = destinations.find(item => item.id === raw);
const invalid = raw !== null && !matched;
const current = matched ?? destinations[0];
const [layout, setLayout] = useState<Layout>('drawer');
const [width, setWidth] = useState(0);
const [cap, setCap] = useState('available');
const [compactBar, setCompactBar] = useState(false);
const [expanded,setExpanded]=useState(false);
const [rtl, setRtl] = useState(false);
const [open, setOpen] = useState(false);
const [hold, setHold] = useState(false);
const [phase, setPhase] = useState<Phase>('idle');
const [requested, setRequested] = useState<Destination | null>(null);
const [failed, setFailed] = useState(false);
const [draft, setDraft] = useState('English-only adaptive workspace draft');
const [compact, setCompact] = useState(false);
const [saved, setSaved] = useState(false);
const [message, setMessage] = useState('Local workspace ready. No remote request or save has occurred.');
const shell = useRef<HTMLDivElement>(null);
const persistentNav = useRef<HTMLElement>(null);
const opener = useRef<HTMLButtonElement>(null);
const heading = useRef<HTMLHeadingElement>(null);
const drawerTitle = useRef<HTMLHeadingElement>(null);
const previousQuery = useRef(raw);
const closeTarget = useRef<'opener' | 'arrival' | 'adaptation'>('opener');
const focusOpenerAfterResize = useRef(false);
const focusLinkAfterResize = useRef<string | true | null>(null);
const measuredLayout = useRef<Layout>('drawer');
const pending = phase !== 'idle';
function focusArrival() {
heading.current?.scrollIntoView({block:'start'});
heading.current?.focus({preventScroll:true});
}
function href(target: Destination) {
const query = new URLSearchParams(search.toString());
query.set('demoDestination',target);
return `/patterns/navigation?${query.toString()}#adaptive-workspace-arrival`;
}
function commit(target: Destination) {
setRequested(target);setPhase('routing');setFailed(false);
try { window.history.pushState(null,'',href(target)); }
catch { setPhase('idle');setFailed(true);setMessage('The local URL could not be changed. Loaded context and draft remain; retry is available.'); }
}
function request(target: Destination) {
if (pending) return;
if (target === current.id && !invalid) {
setMessage(`Local ${current.id} reselected; returned to its heading. Task values remain.`);
if(open){closeTarget.current='arrival';setOpen(false);}else focusArrival();
return;
}
setRequested(target);setFailed(false);
if (hold) { setPhase('held');setMessage(`Local ${target} requested; loaded ${current.id} remains visible. Choose an explicit outcome.`); }
else commit(target);
}
function follow(event: MouseEvent<HTMLAnchorElement>, target: Destination) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.defaultPrevented) return;
event.preventDefault();request(target);
}
function outcome(result: 'complete' | 'fail' | 'cancel') {
if (phase !== 'held' || !requested) return;
if (result === 'complete') { commit(requested);return; }
setPhase('idle');setFailed(result === 'fail');
setMessage(result === 'fail' ? `Local ${requested} failed. Loaded ${current.id} and its draft remain; retry is available.` : `Local destination request cancelled. Loaded ${current.id} remains.`);
if (result === 'cancel') setRequested(null);
if (open) drawerTitle.current?.focus();else focusArrival();
}
useEffect(() => {
if (previousQuery.current === raw) return;
previousQuery.current = raw;
setRequested(null);setPhase('idle');setFailed(false);
setMessage(`Local ${current.id} loaded from the actual URL. Page-local task values remain; no remote write occurred.`);
if (open) { closeTarget.current='arrival';setOpen(false); }
else {
// Native history can restore fragment focus after React's commit.
// Let that settle, then focus the actual arriving task; cancel stale frames.
let second: number | undefined;
const first=requestAnimationFrame(()=>{
second=requestAnimationFrame(()=>{
if (new URLSearchParams(window.location.search).get('demoDestination') === raw) focusArrival();
});
});
return ()=>{cancelAnimationFrame(first);if(second !== undefined)cancelAnimationFrame(second);};
}
},[raw,current.id,open]);
useEffect(() => {
const node = shell.current;
if (!node) return;
const observer = new ResizeObserver(entries => {
const measured = entries[0]?.contentRect.width ?? 0;
const next: Layout = measured < 480 && !compactBar ? 'drawer' : measured < 720 ? 'bar' : 'rail';
setWidth(Math.round(measured));
if (next !== measuredLayout.current) {
if (persistentNav.current?.contains(document.activeElement)) {
if (next === 'drawer') focusOpenerAfterResize.current=true;
else focusLinkAfterResize.current=document.activeElement?.getAttribute('data-adaptive-destination') ?? true;
}
if (next !== 'drawer' && opener.current === document.activeElement) focusLinkAfterResize.current=true;
}
measuredLayout.current=next;
setLayout(next);
});
observer.observe(node);
return () => observer.disconnect();
},[compactBar]);
useEffect(() => {
if (layout !== 'drawer' && open) { closeTarget.current='adaptation';setOpen(false); }
if (layout === 'drawer' && focusOpenerAfterResize.current) {
focusOpenerAfterResize.current=false;opener.current?.focus();
}
if (layout !== 'drawer' && focusLinkAfterResize.current) {
const target=focusLinkAfterResize.current;
focusLinkAfterResize.current=null;
const link=target === true ? persistentNav.current?.querySelector<HTMLAnchorElement>('[aria-current=page]') : [...(persistentNav.current?.querySelectorAll<HTMLAnchorElement>('a') ?? [])].find(node=>node.dataset.adaptiveDestination === target);
link?.focus();
}
},[layout,open]);
function navigation(drawer: boolean) {
return <nav ref={drawer ? undefined : persistentNav} aria-label="Local workspace destinations" data-adaptive-nav data-adaptive-persistent-nav={drawer ? undefined : ''} className="adaptive-local-nav" data-presentation={drawer ? 'drawer' : layout} data-expanded={!drawer&&layout==='rail'&&expanded||undefined} data-item-layout={!drawer&&layout==='bar'&&width>=600?'horizontal':undefined}>
{!drawer&&layout==='rail'&&<Button type="button" variant="outline" data-adaptive-expand aria-expanded={expanded} onClick={()=>setExpanded(value=>!value)}>{expanded?'Collapse local rail':'Expand local rail'}</Button>}
<ul>{destinations.map(item => <li key={item.id}><a href={href(item.id)} data-adaptive-destination={item.id} aria-current={current.id === item.id ? 'page' : undefined} aria-disabled={pending || undefined} onClick={event => follow(event,item.id)}><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={current.id===item.id?2.5:1.5} strokeLinejoin="round" aria-hidden="true">{item.icon}</svg><span>{item.label}</span></a></li>)}<li><span className="adaptive-local-unavailable" aria-disabled="true"><span aria-hidden="true">—</span> Remote sources unavailable</span></li></ul>
</nav>;
}
function outcomes() {
return <div data-adaptive-outcomes className="adaptive-local-actions">
{phase === 'held' && <><Button type="button" onClick={()=>outcome('complete')}>Complete local destination</Button><Button type="button" variant="outline" onClick={()=>outcome('fail')}>Fail local destination</Button><Button type="button" variant="outline" onClick={()=>outcome('cancel')}>Cancel local destination</Button></>}
{failed && requested && <Button type="button" variant="outline" onClick={()=>request(requested)}>Retry local destination</Button>}
</div>;
}
return <div data-adaptive-lab className="space-y-4">
<div className="adaptive-local-settings">
<label>Workspace width cap<select aria-label="Workspace width cap" className="mt-input" value={cap} onChange={event=>setCap(event.target.value)}><option value="available">Available width</option><option value="320">320px cap</option><option value="640">640px cap</option><option value="960">960px cap</option></select></label>
<Button type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(value=>!value)}>Use RTL local workspace</Button>
<Button type="button" variant="outline" aria-pressed={compactBar} onClick={()=>setCompactBar(value=>!value)}>Use compact destination bar</Button>
<Button type="button" variant="outline" disabled={pending} aria-pressed={hold} onClick={()=>setHold(value=>!value)}>Hold local destination requests</Button>
</div>
<p data-adaptive-layout>Measured local width: {width}px. Presentation: {layout}. A cap cannot create space wider than its container.</p>
{invalid && <p role="status">The requested destination is invalid; the loaded local fallback is Overview. Choose a valid destination to correct the URL.</p>}
<div ref={shell} className="adaptive-local-shell" dir={rtl ? 'rtl' : 'ltr'} data-layout={layout} style={{maxWidth:cap === 'available' ? undefined : `${cap}px`}}>
<Drawer open={open} onOpenChange={next => {
if (next) closeTarget.current='opener';
if (!next && phase === 'held') { setPhase('idle');setRequested(null);setFailed(false);setMessage('Closing the local drawer cancelled its held request; loaded context remains.'); }
setOpen(next);
}} onOpenChangeComplete={next=>{
if (next) return;
if (closeTarget.current === 'arrival') focusArrival();
if (closeTarget.current === 'adaptation') persistentNav.current?.querySelector<HTMLAnchorElement>('[aria-current=page]')?.focus();
}}>
{layout === 'drawer' && <DrawerTrigger ref={opener} render={<Button type="button" variant="outline" />}>Open local workspace navigation</DrawerTrigger>}
<DrawerPortal><DrawerBackdrop /><DrawerPopup side={rtl ? 'right' : 'left'} dir={rtl ? 'rtl' : 'ltr'} initialFocus={drawerTitle} finalFocus={()=>closeTarget.current === 'arrival' ? heading.current : layout !== 'drawer' ? persistentNav.current?.querySelector<HTMLAnchorElement>('[aria-current=page]') : opener.current} className="adaptive-local-drawer">
<DrawerTitle ref={drawerTitle} tabIndex={-1}>Local workspace navigation</DrawerTitle><DrawerDescription>Real local destinations. Selecting an arriving destination closes this drawer; Escape or close cancels a held request.</DrawerDescription>
{navigation(true)}{outcomes()}<p role="status">{message}</p>
<DrawerClose render={<Button type="button" variant="outline" />}>Close local workspace navigation</DrawerClose>
</DrawerPopup></DrawerPortal>
</Drawer>
{layout === 'rail' && navigation(false)}
<div className="adaptive-local-content">
<h3 ref={heading} id="adaptive-local-heading" tabIndex={-1} data-adaptive-heading className="font-semibold">{current.title}</h3>
<div className="adaptive-local-result" aria-busy={pending}>
{current.id === 'overview' && <><label htmlFor="adaptive-local-draft">Retained local workspace draft</label><Input id="adaptive-local-draft" value={draft} onChange={event=>setDraft(event.target.value)} /><p>No automatic save occurs. The mounted workspace owns this text across local destinations.</p></>}
{current.id === 'reading' && <><Button type="button" variant="outline" aria-pressed={compact} onClick={()=>setCompact(value=>!value)}>Use compact local reading</Button><ol data-adaptive-records className="guidance-list" style={{lineHeight:compact ? 1.4 : 1.8}}>{[1,2,3].map(index=><li key={index}>English-only local reading example {index}</li>)}</ol></>}
{current.id === 'saved' && <><Button type="button" variant="outline" aria-pressed={saved} onClick={()=>setSaved(value=>!value)}>Bookmark local overview</Button>{saved ? <ul data-adaptive-saved><li>Local overview bookmark</li></ul> : <p>No local bookmarks. Use the bookmark control to save this example in page memory.</p>}</>}
<p data-adaptive-retained>Retained draft: {draft || '(empty)'}. Reading spacing: {compact ? 'compact' : 'roomy'}. Local bookmarks: {saved ? 1 : 0}.</p>
</div>
{outcomes()}<p role="status" data-adaptive-status>{message}</p>
<p>Modified clicks, a reload or leaving this page create a separate page lifecycle; this example does not promise durable task storage or remote access.</p>
</div>
{layout === 'bar' && navigation(false)}
</div>
</div>;
}
export function AdaptiveNavigationExample() {
return <section id="adaptive-workspace-arrival" tabIndex={-1} aria-label="Adaptive local workspace"><Suspense fallback={<p>Loading the local adaptive workspace…</p>}><AdaptiveWorkspace /></Suspense></section>;
}
Anatomy and authored specifications
| Part | This web pattern |
|---|---|
| Destination model | Three stable fixture identities with real query hrefs, visible labels and decorative 24px stroke icons. Remote sources unavailable is informational non-link text. |
| Current destination | Derived from the validated URL, not the most recently clicked link. Exactly one loaded destination has aria-current=page in the active navigation. |
| Bar targets | Equal auto-fit columns with a 5rem minimum (bounded by available width), minimum 64px height, 24px icon, token-based text-sm label and 8px inline padding. At 200% text the columns can reflow instead of splitting labels into characters. At measured widths ≥600px the items become horizontal; narrower items stack icon/label. |
| Rail targets | 8rem collapsed/14rem expanded navigation column (128/224px at a 16px root), with 12px logical boundary spacing. The real expansion control reflows content without changing the route or task values. Expanded links use icon/label rows and fill the target width. |
| Drawer targets | Shared modal Drawer at the physical left/right edge chosen explicitly from DOM direction. Native links are at least 44px; title/close/outcomes remain in its scrolling surface. Long action labels wrap within the surface. |
| Focus and current cues | Independent 2px focus outline; current link uses immediate high-contrast fill, border and bold text. No animated foreground/background inversion. |
| Result and status | The retained result region exposes aria-busy during a request. Live status and recovery actions remain outside that busy region. Drawer content has a local status while its background is inert. |
| Direction / reduced motion / forced colors | DOM dir reaches the portal as well as the workspace. Logical rail edges and natural link ordering adapt. Shared Drawer honors reduced motion; authored forced-color rules retain current/focus boundaries. |
Style source and composition contract
Copy adaptive-navigation.tsx and its adjacent adaptive-navigation.css together into a Next App Router client area that has the shared UI stylesheet. Render AdaptiveNavigationExample: its static, named arrival region stays outside the useSearchParams Suspense boundary. These are checked source files, not dedicated public NavigationBar or NavigationRail exports. Preserve native nav/ul/li/a semantics, actual hrefs, event guards and mounted state ownership when extracting the composition.
.adaptive-local-settings, .adaptive-local-actions { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
.adaptive-local-settings label { display: flex; flex-direction: column; gap: 8px; max-width: 100%; }
.adaptive-local-settings .mt-btn, .adaptive-local-content .mt-btn, .adaptive-local-drawer .mt-btn { min-width: 0; max-width: 100%; min-height: 44px; height: auto; white-space: normal; overflow-wrap: anywhere; }
.adaptive-local-shell { display: flex; flex-direction: column; gap: 16px; width: 100%; min-width: 0; border-block: 1px solid var(--color-muted-foreground); padding-block: 16px; }
.adaptive-local-shell[data-layout="rail"] { flex-direction: row; align-items: start; }
.adaptive-local-content { display: flex; flex-direction: column; gap: 12px; min-width: 0; flex: 1; }
.adaptive-local-result { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.adaptive-local-content p { overflow-wrap: anywhere; }
.adaptive-local-nav { min-width: 0; max-width: 100%; }
.adaptive-local-nav ul { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; }
.adaptive-local-nav li { min-width: 0; }
.adaptive-local-nav a { display: flex; align-items: center; gap: 8px; min-width: 44px; min-height: 44px; padding: 8px 12px; border: 2px solid transparent; border-radius: var(--radius-md); color: var(--color-foreground); background: var(--color-background); text-decoration: underline; text-underline-offset: .2em; overflow-wrap: anywhere; }
.adaptive-local-nav a svg { flex-shrink: 0; }
.adaptive-local-nav a:hover { border-color: var(--color-muted-foreground); }
.adaptive-local-nav a[aria-current="page"] { color: var(--color-background); background: var(--color-foreground); border-color: var(--color-foreground); font-weight: 700; }
.adaptive-local-nav a[aria-disabled="true"] { cursor: progress; }
.adaptive-local-nav a:focus-visible, .adaptive-local-content h3:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
.adaptive-local-nav[data-presentation="bar"] ul { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,5rem),1fr)); }
.adaptive-local-nav[data-presentation="bar"] a, .adaptive-local-nav[data-presentation="rail"] a { flex-direction: column; justify-content: center; min-height: 64px; font-size: var(--text-sm); text-align: center; }
.adaptive-local-nav[data-presentation="bar"] a { padding-inline: 8px; }
.adaptive-local-nav[data-presentation="bar"] li:last-child { grid-column: 1 / -1; }
.adaptive-local-nav[data-presentation="rail"] { flex: 0 0 8rem; border-inline-end: 1px solid var(--color-muted-foreground); padding-inline-end: 12px; }
.adaptive-local-nav[data-presentation="rail"] ul, .adaptive-local-nav[data-presentation="drawer"] ul { flex-direction: column; }
.adaptive-local-nav[data-presentation="rail"] a { width: 100%; }
.adaptive-local-unavailable { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 8px 4px; color: var(--color-muted-foreground); font-size: var(--text-sm); overflow-wrap: anywhere; }
.adaptive-local-drawer { display: flex; flex-direction: column; gap: 16px; }
@media (forced-colors: active) {
.adaptive-local-shell, .adaptive-local-nav[data-presentation="rail"] { border-color: CanvasText; }
.adaptive-local-nav a[aria-current="page"] { border-color: Highlight; text-decoration-thickness: 3px; }
.adaptive-local-nav a:focus-visible, .adaptive-local-content h3:focus-visible { outline-color: Highlight; }
}
.adaptive-local-nav[data-presentation="rail"][data-expanded] { flex-basis: 14rem; }
.adaptive-local-nav[data-presentation="rail"][data-expanded] a { flex-direction: row; justify-content: start; text-align: start; }
.adaptive-local-nav [data-adaptive-expand] { width: 100%; margin-block-end: 8px; }
.adaptive-local-nav[data-presentation="bar"][data-item-layout="horizontal"] a { flex-direction: row; }
Native links, query state and route ownership
- Primary unmodified activation is intercepted only for this mounted local workspace. Successful completion adds the validated destination URL with the native History API; Next useSearchParams supplies the actual selected activity. Unrelated query parameters are retained.
- Control/Meta/Shift/Alt clicks and other mouse buttons retain native href behavior. A separate page load initializes fresh local task values; do not promise durable drafts from mounted React state.
- Back/forward updates actual selected activity and current link, clears obsolete held requests and focuses the arriving context. Initial page hydration does not deliberately steal focus from the document.
- Invalid destination input is visibly explained and renders an Overview fallback. A valid link corrects the URL, including choosing Overview from an invalid entry.
- This is a fixed local fixture, not remote routing success, authorization or a fetch result. Real server routes need actual loader/error boundaries, stable request identity, abort/version policy and persisted evidence for any write.
Requested, loaded and recoverable states
| State | Actual behavior |
|---|---|
| Ready / already current | Loaded content and current link agree. Re-selecting an already valid current destination returns focus/scroll to its heading without adding history or resetting task values; an open drawer closes to that heading. |
| Held request | Requested and loaded identities remain distinct. Normal duplicate navigation is rejected; loaded content/draft remain visible. Explicit complete/fail/cancel controls remain usable. |
| Completed | Only after the URL-selected activity renders does current navigation change and arrival focus move. An open drawer closes to the arriving heading. |
| Failed / retry | Loaded URL/content/draft survive; the retry target remains. Retry follows the same local hold policy, and can be completed or cancelled. |
| Cancelled | No destination URL is promoted. Closing/Escape from a drawer cancels its held request and restores its opener. |
| Resize during a request | Changing presentation retains the local pending task. A drawer becoming persistent closes to its current destination link; recovery controls remain in the workspace. |
| Unavailable | Informational content has no fake href or activation. Destination availability never establishes cultural authority, source permission or authenticated access. |
Focus across arrival and adaptation
- Native links remain keyboard links: Tab/Shift+Tab follows DOM order and Enter activates. Do not add tab-list or command-menu roles or assume arrow-key selection. Bar placement after content and rail placement before content follow their actual DOM order.
- Opening the modal drawer focuses its title; available links, outcome actions and close control remain within its focus boundary. Escape/close returns to the opener when it survives.
- When a focused persistent link becomes a compact drawer, focus moves to the new opener. When the focused opener becomes persistent navigation, focus moves to the current link. Bar/rail restructuring preserves the focused destination identity.
- If an open drawer becomes persistent, close it and focus the surviving current destination link. Do not focus an opener that has disappeared or leave a modal surface trapped after a breakpoint change.
- After a URL-selected activity arrives, focus and scroll its actual heading below the fixed document header. A static native fragment region supports full-entry arrival; client activation separately owns task-heading focus.
Related views retain a different kind of context
This tab task retains one draft across local views and explicit preview outcomes. It intentionally does not create destination history. Keep this distinction when choosing between a navigation bar and similarly shaped tab/segmented controls.
Selected local view: draft. Manual activation. Remote review is unavailable.
Common failures and verification
- Do not expose two competing visible/focusable primary navigation copies. A closed drawer portal is absent; a modal drawer makes its background inert. Keep active navigation count, current state and focus under test.
- Do not promote a requested destination after an empty/failed response or discard unsaved state solely because the layout changed. Separate route, task, loading and presentation ownership.
- Use both pointer and keyboard to prove actual URL/content/current state, retained draft/settings/bookmarks, direct/invalid entry, unrelated query preservation and history. Test failure/retry/cancel and drawer Escape/close independently from selection.
- Exercise the actual measured thresholds, narrow compact-bar mode, long content, both themes, RTL, reduced motion, zoom/text spacing and forced colors. Inspect bounded drawer geometry and reachable recovery.
- Run test:adaptive-navigation, checked-source/token freshness, lint/typechecks, the production build and the complete route/fragment crawl. Browser checks do not establish spoken announcements, hardware keyboard/virtual-keyboard behavior, durable storage or whole-consumer compatibility.