Skip to content
111 changes: 111 additions & 0 deletions apps/sandbox/src/app/(sandbox)/pages/motion-lab/LabDemos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ import {
} from './proposed/TabList';
import {Skeleton} from '@astryxdesign/core/Skeleton';
import {Token} from '@astryxdesign/core/Token';
import {
TopNav,
TopNavHeading,
TopNavMegaMenu,
TopNavMegaMenuItem,
} from '@astryxdesign/core/TopNav';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {useMotionLab} from './MotionLabStore';
Expand Down Expand Up @@ -897,6 +903,111 @@ export function OriginRig({kind}: {kind: 'zero' | 'centre' | 'anchored'}) {
);
}

// --- TopNav mega-menu exit ----------------------------------------------------

const MEGA_MENU_LOOP_MS = 3200;
const MEGA_MENU_OPEN_MS = 1200;

/**
* The real TopNav mega-menu, opened and closed on a loop through its own
* trigger. Before is core as it ships: the panel rises 4px and fades on open
* and close, and does the same under Reduce Motion. After is the same
* component with the missing branch as a sandbox prototype
* (MotionLab.module.css `.megaMenuProposed`): under Reduce Motion the panel
* fades within 200 ms on a touch screen and changes at once with a mouse
* (design:reduced-motion DR2, DR3); the lab's Degrade shows the fade, its
* Delete the cut.
* The two panes open half a loop apart: opening one auto popover closes any
* other, so in step they would close each other.
*/
export function TopNavExitRig({mode}: {mode: Mode}) {
const {reducedMotion} = useMotionLab();
const rootRef = useRef<HTMLDivElement>(null);
const timersRef = useRef<number[]>([]);
const setOpen = useCallback((isOpen: boolean) => {
const trigger = rootRef.current?.querySelector<HTMLButtonElement>(
'button[aria-expanded]',
);
if (trigger == null || (trigger.ariaExpanded === 'true') === isOpen) {
return;
}
// A pointer-style click (detail 1): `click()` sends detail 0, which the
// menu reads as Enter or Space and answers by moving focus into the
// panel. Focus stays wherever the person left it.
const focused = document.activeElement;
trigger.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
cancelable: true,
detail: 1,
view: window,
}),
);
const moved = document.activeElement;
if (moved === focused || !(moved instanceof HTMLElement)) {
return;
}
if (focused instanceof HTMLElement && focused !== document.body) {
focused.focus({preventScroll: true});
} else {
// Nothing had focus: leave nothing focused.
moved.blur();
}
}, []);
useLoop(() => {
timersRef.current.forEach(window.clearTimeout);
const offset = mode === 'before' ? 0 : MEGA_MENU_LOOP_MS / 2;
timersRef.current = [
window.setTimeout(() => setOpen(true), offset),
window.setTimeout(() => setOpen(false), offset + MEGA_MENU_OPEN_MS),
];
}, MEGA_MENU_LOOP_MS);
useEffect(() => () => timersRef.current.forEach(window.clearTimeout), []);

return (
<VStack gap={2} {...stylex.props(sx.full)}>
<div
ref={rootRef}
className={mode === 'after' ? styles.megaMenuProposed : undefined}
data-reduced-motion={mode === 'after' ? reducedMotion : undefined}>
<TopNav
label={mode === 'before' ? 'Today' : 'Proposed'}
heading={<TopNavHeading heading="Acme" />}
startContent={
<TopNavMegaMenu
label="Products"
items={
<>
<TopNavMegaMenuItem
title="Analytics"
description="Track behavior across your apps"
href="#analytics"
/>
<TopNavMegaMenuItem
title="Security"
description="Protection for your data"
href="#security"
/>
<TopNavMegaMenuItem
title="Automation"
description="Workflows that run themselves"
href="#automation"
/>
</>
}
/>
}
/>
</div>
<Hint>
{mode === 'before'
? 'An exit that travels, and the proof the pattern works: the panel rises 4px and fades on open and close. Under Reduce Motion it does exactly the same.'
: 'The same exit with the missing branch: under Reduce Motion the panel fades within 200 ms on a touch screen and changes at once with a mouse; the lab\u2019s Degrade shows the fade, Delete the cut. Turn either on to compare.'}
</Hint>
</VStack>
);
}

// --- loops -------------------------------------------------------------------

export function LoopRig({policy}: {policy: 'today' | 'delete' | 'degrade'}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,38 @@
}
}

/* ------------------------------------------------- TopNav mega-menu ---- */

/* A sandbox prototype of the reduced-motion branch TopNav's mega-menu panel
lacks. Under Reduce Motion it never rises: on a touch screen it only
fades, no longer than the 200 ms cross-fade, and with a mouse it changes
at once (design:reduced-motion DR1-DR3). The panel stays a DOM descendant
in the top layer, so the scope reaches it. The lab's Degrade shows the
fade; Delete cuts. */
@media (prefers-reduced-motion: reduce) and (pointer: coarse) {
.megaMenuProposed [popover] {
transform: none !important;
transition-duration: min(var(--duration-medium-min), 200ms) !important;
}
}

@media (prefers-reduced-motion: reduce) and (not (pointer: coarse)) {
.megaMenuProposed [popover] {
transform: none !important;
transition-duration: 0s !important;
}
}

.megaMenuProposed[data-reduced-motion='degrade'] [popover] {
transform: none !important;
transition-duration: min(var(--duration-medium-min), 200ms) !important;
}

.megaMenuProposed[data-reduced-motion='delete'] [popover] {
transform: none !important;
transition-duration: 0s !important;
}

/* ---------------------------------------------------------------- tint ---- */

/* The Table hover tint, at whatever duration and curve is being argued over.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ export const PREVIEW_PLAN: ReadonlyArray<PreviewRow> = [
motionCost: 'idle',
dirs: ['TopNav'],
today: 'Mega-menu with a real exit, sub-lists, chevrons.',
gap: 'The only component animating a popover exit, and it never checks reduced motion.',
gap: 'Its panel exit travels, and it never checks reduced motion.',
shows:
'Its exit as the existing proof the pattern works, plus the missing reduced-motion branch.',
hardCase:
Expand All @@ -582,8 +582,8 @@ export const PREVIEW_PLAN: ReadonlyArray<PreviewRow> = [
'reduced motion',
'traverse speed',
],
status: 'specified',
rig: null,
status: 'built',
rig: 'TopNavExitRig',
priority: 1,
measurementNote: null,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
SkeletonSwapRig,
StatCountRig,
TabIndicatorRig,
TopNavExitRig,
} from '../LabDemos';

export default function PreviewsPage() {
Expand Down Expand Up @@ -196,6 +197,34 @@ export default function PreviewsPage() {
</DemoBody>
</DemoCard>

<DemoCard
title="TopNav mega-menu exit"
question="An anchored panel whose exit travels, and it never checks Reduce Motion."
badges={<Badge variant="warning" label="PARTIAL" />}>
<ComparePanes
panes={[
{
tone: 'before',
label: 'Today — the same rise under Reduce Motion',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main, #8612 gives core's mega-menu panel this same reduced-motion branch, so once this merges the Today pane no longer rises under Reduce Motion: in this head's CI sandbox build (merged with main) both panes open identically. This label, the question on line 202, the body on lines 220-222, the hint at LabDemos.tsx line 1004 and the plan's gap line need rewriting for the shipped branch.

content: <TopNavExitRig mode="before" />,
},
{
tone: 'after',
label: 'Proposed — Reduce Motion never rises',
content: <TopNavExitRig mode="after" />,
},
]}
/>
<DemoBody>
<Text type="supporting" color="secondary">
Both panes are core&rsquo;s real <code>TopNav</code>. The right one
carries the missing reduced-motion branch as a sandbox prototype in
CSS. Animator retargeting would come with the proposed public{' '}
<code>useAnimator</code> (AST-107, spec PR #8600).
</Text>
</DemoBody>
</DemoCard>

<DemoCard
title="Chip add and remove"
question="Tokenizer, Token and FileInput share this. Removal is the hard half: the chip has to leave while its neighbours close the gap."
Expand Down
Loading