/** * `` — HeroUI-themed off-canvas drawer for `@sigx/lynx-navigation`. * * Composes the primitive `` as the state provider (so `useDrawer()` * resolves for descendants) and drives its own `SharedValue`-backed slide + * fade via `@sigx/lynx-motion`: * - Panel translates from off-screen on `side` to 0 on open (back on close). * - Backdrop fades 0 → 0.3 in tandem. * - Chrome mounts on open and unmounts after the exit animation, so a closed * drawer doesn't intercept taps. * * ```tsx * * }}> * * * * ``` */ import { TOUCH_GUARD_TAG } from '@sigx/lynx-gestures'; import { component, effect, onUnmounted, runOnMainThread, signal, untrack, useAnimatedStyle, useMainThreadRef, useSharedValue, type Define, type JSXElement, type MainThread, type SharedValue, } from '@sigx/lynx'; import { withTiming } from '@sigx/lynx-motion'; import { Drawer, useDrawer } from '@sigx/lynx-navigation'; import { resolveColorToken, type BackgroundValue } from '@sigx/lynx-zero'; export type NavDrawerSide = 'left' | 'right'; export type NavDrawerProps = /** Which edge the panel slides in from. Default 'left'. */ & Define.Prop<'side', NavDrawerSide, false> /** Panel surface color. Semantic tokens ('base-100', 'primary', …) apply as * a `bg-` class (the preset compiles it to a resolvable `var()` * rule); raw CSS strings apply inline. Default 'base-100'. */ & Define.Prop<'background', BackgroundValue, false> /** Show a separator on the panel's inner edge. Default true. */ & Define.Prop<'bordered', boolean, false> /** Dismiss-on-tap scrim over the main content when open. Default true. */ & Define.Prop<'backdrop', boolean, false> /** Panel width in pixels. Default 280. */ & Define.Prop<'width', number, false> /** Open at mount. Default false. Passthrough to the primitive ``. */ & Define.Prop<'initialOpen', boolean, false> /** Drawer panel contents — your menu UI. */ & Define.Slot<'sidebar'> /** Main content — usually a `` or ``. */ & Define.Slot<'default'>; const ENTER_DURATION_SEC = 0.28; const EXIT_DURATION_SEC = 0.22; // `ceil` so the unmount timer never fires before the exit animation finishes // (which would pop the panel/backdrop out instead of letting it slide). const EXIT_DURATION_MS = Math.ceil(EXIT_DURATION_SEC * 1000); const BACKDROP_OPACITY = 0.3; // Raw guard tag as a dynamic intrinsic (same pattern as lynx-sheet's // Backdrop guardTag) - the backdrop needs main-thread:ref + accessibility // attrs the wrapper doesn't forward. const GuardRoot = TOUCH_GUARD_TAG as any; export const NavDrawer = component(({ props, slots }) => { return () => ( {slots.default?.()} ); }); type NavDrawerShellProps = & Define.Prop<'side', NavDrawerSide, true> & Define.Prop<'background', BackgroundValue, true> & Define.Prop<'bordered', boolean, true> & Define.Prop<'backdrop', boolean, true> & Define.Prop<'width', number, true> & Define.Prop<'renderSidebar', (() => JSXElement | JSXElement[]) | undefined, false> & Define.Slot<'default'>; const NavDrawerShell = component(({ props, slots }) => { const drawer = useDrawer(); const progress = useSharedValue(drawer.isOpen ? 1 : 0); const shouldRender = signal(drawer.isOpen); let chromeMounted = drawer.isOpen; let exitTimer: ReturnType | null = null; const openAnim = runOnMainThread(() => { 'main thread'; withTiming(progress, 1, { duration: ENTER_DURATION_SEC }); }); const closeAnim = runOnMainThread(() => { 'main thread'; withTiming(progress, 0, { duration: EXIT_DURATION_SEC }); }); const animRunner = effect(() => { const open = drawer.isOpen; if (open) { if (exitTimer != null) { clearTimeout(exitTimer); exitTimer = null; } chromeMounted = true; untrack(() => { shouldRender.value = true; }); openAnim(); } else if (chromeMounted) { chromeMounted = false; closeAnim(); // Wait for the exit animation before unmounting the chrome — else the // panel pops out instead of sliding, and the backdrop tap area // disappears mid-fade. exitTimer = setTimeout(() => { untrack(() => { shouldRender.value = false; }); exitTimer = null; }, EXIT_DURATION_MS); } }); onUnmounted(() => { animRunner.stop(); if (exitTimer != null) clearTimeout(exitTimer); }); return () => ( {slots.default?.()} {shouldRender.value ? ( drawer.close()} /> ) : null} ); }); type DrawerChromeProps = & Define.Prop<'side', NavDrawerSide, true> & Define.Prop<'progress', SharedValue, true> & Define.Prop<'width', number, true> & Define.Prop<'background', BackgroundValue, true> & Define.Prop<'bordered', boolean, true> & Define.Prop<'backdrop', boolean, true> & Define.Prop<'renderSidebar', (() => JSXElement | JSXElement[]) | undefined, false> & Define.Prop<'onBackdropPress', () => void, true>; const DrawerChrome = component(({ props }) => { const panelRef = useMainThreadRef(null); const backdropRef = useMainThreadRef(null); // Left starts at `-width` (off-screen) and lands at 0; right at `+width`. const closedTx = props.side === 'right' ? props.width : -props.width; useAnimatedStyle(panelRef, props.progress, 'translateX', { inputRange: [0, 1], outputRange: [closedTx, 0], }); // Registered unconditionally so a runtime `backdrop` toggle binds both ways; // when the backdrop view isn't rendered the ref is null and the apply skips. useAnimatedStyle(backdropRef, props.progress, 'opacity', { inputRange: [0, 1], outputRange: [0, BACKDROP_OPACITY], }); return () => { const isRight = props.side === 'right'; // `resolveColorToken` rewrites a semantic token to `var(--color-*)` and // passes raw CSS values through unchanged. We use that only to branch: // semantic tokens apply via the `bg-` class (the preset compiles // it to a resolvable rule); raw CSS color strings apply inline. const resolved = resolveColorToken(props.background); const isToken = resolved !== props.background; const bgClass = isToken ? `bg-${props.background}` : ''; const borderClass = props.bordered ? (isRight ? 'border-l border-base-300' : 'border-r border-base-300') : ''; const panelClass = [bgClass, borderClass].filter(Boolean).join(' '); const panelStyle: Record = { position: 'absolute', top: 0, bottom: 0, width: props.width, }; if (!isToken) panelStyle.backgroundColor = props.background; if (isRight) panelStyle.right = 0; else panelStyle.left = 0; return ( {props.backdrop ? ( props.onBackdropPress()} class="bg-base-content" style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0 }} accessibility-element={true} accessibility-label="Close drawer" accessibility-trait="button" /> ) : null} {props.renderSidebar?.()} ); }; });