feat(desktop): hover-reveal collapsed sidebars as fixed overlays (#41670)
* feat(desktop): hover-reveal collapsed chat sidebar as a fixed overlay
When the sessions sidebar is collapsed, hovering the left edge now floats
it back in as a fixed overlay over the main content instead of just being
hidden. The collapsed grid track stays at 0px so the panel never reserves
space — it slides over whatever's underneath and retracts on pointer-leave.
- PaneShell: new hoverReveal prop. When a pane is collapsed + hoverReveal,
render an edge hot-zone + a side-anchored floating panel (absolute, full
height, honors any persisted resize width) that slides in on hover/focus.
- ChatSidebar: force the (otherwise opacity-0 when collapsed) sidebar fully
visible + interactive while the overlay is revealed, via an
in-data-[pane-hover-reveal=open] variant.
- desktop-controller: opt the chat-sidebar pane into hoverReveal.
* feat(desktop): lower window minWidth 900→400
Lets the window shrink to a narrow rail (e.g. for the collapsed
hover-reveal sidebar) instead of being floored at 900px.
* fix(desktop): render full sidebar content in hover-reveal overlay
The hover-reveal overlay showed only the nav rail — session rows, search,
pinned/recents were gated behind `sidebarOpen` (false while collapsed), so
they never mounted in the floated panel.
Add a $sidebarRevealed store the PaneShell overlay drives via a new
onHoverRevealChange callback, and gate ChatSidebar's content on
`sidebarOpen || sidebarRevealed` (contentVisible) instead of raw open
state. The overlay now shows the complete sidebar.
* fix(desktop): drop shadow on hover-reveal sidebar overlay
* feat(desktop): hover-reveal the file-browser sidebar too
The reveal mechanism already lives in the shared Pane primitive — the
right rail just opts in with hoverReveal. Its content renders
unconditionally, so (unlike the chat sidebar) it needs no extra
content-visibility gating.
* clean(desktop): tighten hover-reveal pane code
KISS pass — flatten the translate ternary, derive a single `revealed`,
inline the edge style, drop the redundant set-guard, and trim comments to
the house one-liner style. No behavior change.
* fix(desktop): stop hiding sidebar nav labels on narrow windows
The nav labels (New session, Skills, …) and the ⌘N hint were gated on a
viewport breakpoint (max-[46.25rem]:hidden), so shrinking the window hid
them even when the sidebar itself was wide — including in the hover-reveal
overlay. Drop the gate; the label already truncates (min-w-0 flex-1) so it
ellipsizes gracefully in a narrow rail, and contentVisible already hides it
when collapsed to the icon rail.
* feat(desktop): auto-collapse both sidebars below 600px into hover-reveal
Add a Pane `forceCollapsed` prop — collapses the track without writing to
the store (so the saved open state restores when the window widens) while
keeping hoverReveal alive (unlike `disabled`, which suppresses it).
desktop-controller watches (max-width: 600px) and force-collapses the chat
sidebar + file browser, so on a narrow window both rails get out of the way
and the hover-reveal overlay becomes the way in.
* feat(desktop): hover-intent + refined easing for sidebar reveal
- Gate the reveal on pointer velocity: the full-height edge hot-zone now
only arms on a slow, deliberate pass (<=0.55 px/ms). Fast sweeps toward
the titlebar/statusbar — or off the window — blow past the threshold and
never trigger, so the wide hit area stops being a nuisance.
- Swap the slide easing to cubic-bezier(0.32,0.72,0,1) at 260ms (snappy-out,
soft-land) for a more serious-app feel.
* fix(desktop): don't reveal sidebar during window resize
Resizing the window parks the cursor on the screen edge and fires slow
pointermoves over the hot-zone, reading as deliberate intent. Guard the
reveal on (a) e.buttons !== 0 — any button-held drag, incl. edge-resize —
and (b) a 250ms cooldown after any window resize event.
* feat(desktop): hoverIntent-style poll gate + inert contents during slide
Replace the single-sample velocity check (too eager — fired on any one slow
move, incl. resize drift) with a port of Brian Cherne's hoverIntent: poll
the pointer every 90ms and only arm once it has *settled* (moved <5px between
two consecutive polls inside the edge zone). Fly-bys, pass-throughs, and
resize drift never produce two close samples in a row, so they don't trigger.
Also keep the revealed panel's CONTENTS pointer-events-none until the slide-in
transition finishes (onTransitionEnd → settled), so you can't misclick a
session row mid-animation. Resets on retract.
* fix(desktop): no cursor/hit-test leak before reveal settles
The edge hot-zone showed cursor:pointer the instant the pointer touched it —
before the panel was armed or in view. And contents were inert but the panel
itself still hit-tested, so the cursor could flip mid-slide. Fix: hot-zone is
cursor-default (it's invisible), and the whole panel is pointer-events-none
until revealed && settled, so the cursor never changes or lands on a row
before the slide-in finishes.
* fix(desktop): geometry-driven close so revealed panel always retracts
The revealed panel relied on its own onPointerLeave to close — but a panel
that slid in under a still cursor (or whose contents were inert during the
slide) never fires enter/leave, so it got stuck open (esp. the file browser).
onTransitionEnd also bubbled from the file-tree's own row transitions,
tripping the settled flag wrongly.
Replace with a document-level pointermove watcher that closes once the cursor
leaves the panel's bounding rect + a 24px grace — independent of pointer-events
state or what the contents do. Gate interactivity on a simple slide-duration
timer (interactive) instead of the fragile transitionEnd, so the cursor still
can't flip or land on a row before the panel is in view.
* feat(desktop): make sidebar toggle shortcuts reveal when force-collapsed
mod+b / mod+j were no-ops on a narrow (force-collapsed) window — they
flipped the store but the pane ignores it. Now the toggle handlers also
dispatch PANE_TOGGLE_REVEAL_EVENT; a force-collapsed Pane listens (only while
overlayActive) and flips its hover-reveal, so the shortcut floats the rail in
(and back out) at this responsive breakpoint.
* refactor(desktop): name the 600px sidebar collapse breakpoint
Hoist the inline '(max-width: 600px)' literal into
SIDEBAR_COLLAPSE_BREAKPOINT_PX + SIDEBAR_COLLAPSE_MEDIA_QUERY in
layout-constants, so the responsive collapse point is a single named source
of truth instead of a magic string in the controller.
* tweak(desktop): sidebar auto-collapse breakpoint 600px -> 768px
768 is the standard md breakpoint and a more honest 'no room to dock' point.
* tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms
* Revert "tweak(desktop): halve sidebar reveal slide duration 260ms -> 130ms"
This reverts commit 6009a13200.
* perf(desktop): pre-mount hover-reveal contents to kill slide-in stall
The reveal mounted the (heavy, virtualized) sidebar contents in the same
frame the slide started, so the browser stalled painting the transform until
the mount finished — a ~100-200ms beat before the panel moved, very visible
on the instant keyboard toggle (hover masked it via the 90ms intent poll).
Report overlayActive (collapsed-overlay mode) rather than the live reveal
state to the mount consumer, so contents stay mounted off-screen while
collapsed and reveal is a pure transform. Visibility is still driven
separately by the data-pane-hover-reveal attr + the slide transform.
* fix(desktop): make reveal hotkey spammable
Two throttles on the reveal toggle:
- The handler fired both the reveal event AND toggleSidebarOpen() per press;
the store write hits localStorage synchronously every keystroke + recomputes
the grid, janking rapid presses. When collapsed, only dispatch the reveal
event (the store toggle was a no-op anyway).
- The geometry close-watcher slammed a keyboard-opened panel shut on the first
stray pointermove (trackpad jitter), fighting hotkey spam. Keyboard reveals
now ignore geometry until the cursor actually enters the panel, then the
mouse takes over.
* fix(desktop): inset reveal hot-zone past the OS window-resize gutter
The hot-zone sat flush at the window edge (left-0/right-0), overlapping the
OS resize grab strip — reaching to drag-resize naturally slows the cursor
there, which hoverIntent reads as settled and reveals before the resize drag
even starts. Inset the hot-zone 8px so the outermost edge stays a pure
resize/drag region and only an intentful move just inside it arms a reveal.
* fix(desktop): keep reveal hot-zone at edge, gate arming past resize gutter
Insetting the hot-zone made it unreachable when moving fast. Instead, anchor
the zone flush at the edge (w-4, always captures the pointer) but only ARM the
reveal when the cursor settles >=8px in from the edge — so a resize-reach that
parks on the outermost OS grab strip never triggers, while a deliberate move
into the zone still does. Keeps polling while in the gutter so moving inward
still arms.
* refactor(desktop): rebuild hover-reveal as pure CSS, delete the JS state machine
The hand-rolled pointer state machine (hoverIntent poll, refs, timers, document
pointermove geometry-close, interactive gate, resize cooldowns, keyboard-held
suppression) was fragile and side/instance-specific — hover broke on the right
rail, keyboard toggles triggered phantom animations, resize popped it open.
Replace all of it with the native primitive: CSS group-hover drives the slide
transform; a transition-delay on enter (instant on leave) is the hover-intent
gate (a fast pass-by doesn't dwell long enough to open); a thin edge trigger
inset past the OS resize grab strip arms it; and a single `forced` bool
(data-forced, toggled by the keyboard event) pins it open. Side-agnostic by
construction — group-hover doesn't care which edge or which pane.
Net: ~200 lines of imperative pointer logic → ~40 lines of declarative CSS.
* fix(desktop): don't animate hover-reveal panel across viewport on side flip
Flipping panes changed the off-screen transform from -translateX (off the
left) to +translateX (off the right). transition-transform interpolated
between them, passing through translate-x-0 (fully on-screen) mid-way — so the
hidden panel visibly slid across the window to reach its new hiding spot.
Key the panel on side so it remounts off-screen on the new edge with no
transition to play.
* clean(desktop): tighten hover-reveal markup
KISS pass on the CSS-driven reveal: reuse the existing `side` instead of a
local `left`, move the static duration/ease to inline style (drop two
single-use CSS vars + their arbitrary-value classes, keep only the
state-dependent enter-delay var), and trim comments to the house one-liner
density. No behavior change.
* fix(desktop): inset titlebar past traffic lights when sidebar is force-collapsed
The titlebar content inset (clearing the macOS traffic lights) keyed off the
stored sidebarOpen/fileBrowserOpen, but below the collapse breakpoint both
rails are force-collapsed so the left edge is uncovered while the store still
says open — content (the intro wordmark) overflowed under the lights. Gate
leftEdgePaneOpen on !narrowViewport using the shared SIDEBAR_COLLAPSE_MEDIA_QUERY.
Also rename the now-misleading reveal plumbing to match what it actually does:
onHoverRevealChange -> onOverlayActiveChange, $sidebarRevealed ->
$sidebarOverlayMounted (+ setter/consumer). It reports/stores collapsed-overlay
mode (mount gate), not live reveal state.
* feat(desktop): small --nous-shadow lift on revealed hover-reveal panels
Add a --nous-shadow token (white-based on light, black-based on dark) and apply
it to the floating sidebar panel only while revealed (group-hover / data-forced)
so it reads as lifted off the surface. No shadow on the off-screen panel.
* feat(desktop): shadow-reveal lift on revealed hover-reveal panels
Mirror the --shadow-nous layered falloff into a new --shadow-reveal token whose
drop color flips per mode (white on light, black on dark) via --shadow-reveal-raw
set in :root / :root.dark. Apply the generated shadow-reveal utility to the
floated panel only while revealed (group-hover / data-forced). Leaves the shared
--shadow-nous untouched.
* feat(desktop): use tuned reveal shadow, drop per-mode token
Replace the --shadow-reveal token machinery with Brooklyn's tuned literal
(0 -18px 18px -5px #0000003b) inline per-panel via --reveal-shadow, y-offset
sign flipped for the right side. Same color both modes. Reverts styles.css to
pristine (token removed).
* fix(desktop): use the reveal shadow verbatim, don't invert it per side
Flipping the y-offset sign for the right side inverted the shadow's direction
(cast-up -> cast-down), making it read heavier — not a mirror. The mirror axis
for a left/right panel is offset-x, which is 0 here, so both sides take the
tuned value as-is: 0 -18px 18px -5px #0000003b.
* clean(desktop): hoist reveal shadow to a named const
Move the inline reveal-shadow literal to HOVER_REVEAL_SHADOW alongside the
other HOVER_REVEAL_* tuning consts; drop the now-stale per-side comment.
* fix(desktop): truncate titlebar title before the right tool cluster
The session title used a hardcoded max-w-[52vw] that's blind to where the
right-side tools start, so it ran under them at narrow widths / with pane
tools present. Bound the title container by the same vars the titlebar drag
region uses (--titlebar-content-inset + --titlebar-tools-right +
--titlebar-tools-width) so it truncates exactly at the cluster's left edge.
* fix(desktop): responsive markdown tables — floor width + nowrap headers
The wrapper had overflow-x-auto but the table was w-full with auto layout, so
instead of scrolling it crushed columns until even header words broke mid-word
(Tim/e, Nig/ht). Add a min-w-[18rem] floor so it scrolls horizontally when the
column is narrower than readable, and whitespace-nowrap on th so headers never
break mid-word. Above the floor it still wraps cells naturally.
* fix intro
This commit is contained in:
@@ -425,7 +425,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
|
||||
<table
|
||||
className={cn(
|
||||
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -438,7 +438,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
|
||||
th: ({ className, ...props }: ComponentProps<'th'>) => (
|
||||
<th
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
|
||||
'whitespace-nowrap px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -150,10 +150,7 @@ export const Thread: FC<{
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
<div
|
||||
className="flex min-h-0 w-full flex-col items-center justify-center"
|
||||
style={{ paddingBottom: 'var(--composer-measured-height)' }}
|
||||
>
|
||||
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
|
||||
<Intro {...intro} />
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -470,9 +467,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
||||
s =>
|
||||
s.thread.isRunning &&
|
||||
s.message.status?.type === 'running' &&
|
||||
s.message.parts
|
||||
.slice(Math.max(0, startIndex))
|
||||
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
|
||||
)
|
||||
|
||||
// A reasoning group with no actual text is pure noise — drop the whole
|
||||
|
||||
@@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
|
||||
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-0.5 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
|
||||
data-slot="aui_intro"
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
<p
|
||||
aria-label={WORDMARK}
|
||||
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
|
||||
className="fit-text mx-auto mb-1 w-[calc(100%-1rem)] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={{ '--fit-min': '2.75rem' } as CSSProperties}
|
||||
>
|
||||
<span>
|
||||
<span>{WORDMARK}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { PaneShellContextValue, PaneSlot } from './context'
|
||||
export { PaneShellContext } from './context'
|
||||
export { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
|
||||
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -31,6 +32,12 @@ export interface PaneProps {
|
||||
defaultOpen?: boolean
|
||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||
disabled?: boolean
|
||||
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
|
||||
forceCollapsed?: boolean
|
||||
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
|
||||
hoverReveal?: boolean
|
||||
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
|
||||
onOverlayActiveChange?: (overlayActive: boolean) => void
|
||||
id: string
|
||||
maxWidth?: WidthValue
|
||||
minWidth?: WidthValue
|
||||
@@ -53,6 +60,7 @@ export interface PaneShellProps {
|
||||
interface CollectedPane {
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
forceCollapsed: boolean
|
||||
id: string
|
||||
resizable: boolean
|
||||
side: PaneSide
|
||||
@@ -62,6 +70,22 @@ interface CollectedPane {
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
const DEFAULT_RESIZE_MIN_WIDTH = 160
|
||||
|
||||
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
|
||||
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
|
||||
const HOVER_REVEAL_SLIDE_MS = 220
|
||||
const HOVER_REVEAL_ENTER_DELAY_MS = 130
|
||||
const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area.
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 6
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
@@ -110,6 +134,7 @@ function collectPanes(children: ReactNode) {
|
||||
const entry: CollectedPane = {
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
forceCollapsed: props.forceCollapsed ?? false,
|
||||
id: props.id,
|
||||
resizable: props.resizable ?? false,
|
||||
side: props.side,
|
||||
@@ -124,7 +149,7 @@ function collectPanes(children: ReactNode) {
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
const open = !pane.disabled && stateOpen
|
||||
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
|
||||
|
||||
if (!open) {
|
||||
return { open: false, track: '0px' }
|
||||
@@ -193,14 +218,29 @@ export function Pane({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
disabled = false,
|
||||
hoverReveal = false,
|
||||
id,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
resizable = false
|
||||
onOverlayActiveChange,
|
||||
resizable = false,
|
||||
width
|
||||
}: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const paneStates = useStore($paneStates)
|
||||
const registered = useRef(false)
|
||||
const paneRef = useRef<HTMLDivElement | null>(null)
|
||||
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
|
||||
const [forced, setForced] = useState(false)
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
const side = slot?.side ?? 'left'
|
||||
// Collapsed + hoverReveal: float the pane contents over the main column on
|
||||
// hover/focus instead of hiding them. Honors any persisted resize width.
|
||||
const overlayActive = !open && hoverReveal && !disabled
|
||||
const override = resizable ? paneStates[id]?.widthOverride : undefined
|
||||
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {
|
||||
@@ -211,12 +251,34 @@ export function Pane({
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
const slot = ctx?.paneById.get(id)
|
||||
const open = Boolean(slot?.open && !disabled)
|
||||
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
|
||||
// a collapsed overlay (reopened / widened).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !overlayActive) {
|
||||
setForced(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const onToggle = (e: Event) => {
|
||||
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
|
||||
setForced(v => !v)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
|
||||
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
|
||||
}, [id, overlayActive])
|
||||
|
||||
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
|
||||
useEffect(() => {
|
||||
onOverlayActiveChange?.(overlayActive)
|
||||
}, [onOverlayActiveChange, overlayActive])
|
||||
|
||||
const canResize = open && resizable
|
||||
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
|
||||
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
|
||||
const side = slot?.side ?? 'left'
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -273,6 +335,58 @@ export function Pane({
|
||||
return null
|
||||
}
|
||||
|
||||
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
|
||||
// thin edge trigger + the floating panel (both absolute, escaping the zero
|
||||
// box). group-hover (or data-forced from the keyboard) drives the slide; the
|
||||
// enter-delay is the hover-intent gate. No JS pointer math.
|
||||
if (overlayActive) {
|
||||
const edge = side === 'left' ? 'left' : 'right'
|
||||
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
|
||||
data-forced={forced ? '' : undefined}
|
||||
data-pane-hover-reveal={forced ? 'open' : 'closed'}
|
||||
data-pane-id={id}
|
||||
data-pane-open="false"
|
||||
data-pane-side={side}
|
||||
ref={paneRef}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
|
||||
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
|
||||
/>
|
||||
|
||||
{/* Keyed on side so flipping panes remounts off-screen on the new edge
|
||||
instead of transitioning the transform across the viewport. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
|
||||
offscreen,
|
||||
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
|
||||
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
|
||||
)}
|
||||
key={edge}
|
||||
style={
|
||||
{
|
||||
[edge]: 0,
|
||||
width: overlayWidth,
|
||||
'--reveal-shadow': HOVER_REVEAL_SHADOW,
|
||||
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
|
||||
transitionTimingFunction: HOVER_REVEAL_EASE,
|
||||
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
|
||||
Reference in New Issue
Block a user