Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,15 @@ function AppContent() {
screen — see momentStage.ts in the moments feature. */}
<main
data-moment-stage
className={`relative min-h-screen min-w-0 flex-1 pt-[64px] lg:pt-0 ${
className={`app-sidebar-eases relative min-h-screen min-w-0 flex-1 pt-[64px] lg:pt-0 ${
// The sidebar is `fixed` from `lg` up (see SideBar), so it is out of
// flow and the page has to leave its width free itself. In focus mode
// it slides away over the content, so the margin goes with it.
signedIn && !isFocused ? "lg:ml-[var(--app-sidebar-width)]" : ""
// it slides away over the content, so the margin goes with it. Its
// width can be changed and folded, so it is read from the variable
// the sidebar keeps, falling back to the default before it has run.
signedIn && !isFocused
? "lg:ml-[var(--app-sidebar-desktop-width,var(--app-sidebar-width))]"
: ""
}`}
>
<AppRouter />
Expand Down
259 changes: 175 additions & 84 deletions src/components/layout/SideBar.tsx

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions src/components/layout/SidebarAccountFlyout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useLocation } from "react-router-dom";
import { useFocusMode } from "../../context/useFocusMode";

/** Long enough to cross the gap from the icon to the card without it closing in between. */
const CLOSE_DELAY_MS = 200;

type SidebarAccountFlyoutProps = {
/** What the folded rail shows in place of the footer card: the user's avatar. */
trigger: ReactNode;
/** Names the button for assistive technology, since it shows only a picture. */
label: string;
/** The footer card as the open sidebar shows it. */
children: ReactNode;
};

/**
* The sidebar footer for the rail folded to icons: one icon that slides the full footer card
* out beside the rail on hover, or on click and from the keyboard, where there is no hover.
*
* The card stays mounted while hidden (`inert` and transparent) rather than being removed: the
* project switcher inside it owns the Cmd/Ctrl+K shortcut and its modal, and both have to keep
* working while the card is closed -- including after the pointer has left it for the modal.
*
* Drawn in a portal at a fixed position beside the rail, which reads the rail's width from the
* same CSS variable the sidebar does: the nav and footer clip anything past their edge.
*/
export function SidebarAccountFlyout({ trigger, label, children }: SidebarAccountFlyoutProps) {
/**
* Opened by hover it follows the pointer and closes when it leaves; opened by a click (or
* clicked while hovered open) it stays until Escape, a click elsewhere or a second click.
* Without the difference, hovering the icon and then clicking it -- the natural thing to do
* with a mouse -- closed the card the hover had just opened.
*/
const [mode, setMode] = useState<"closed" | "hover" | "pinned">("closed");
const open = mode !== "closed";
const panelId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const closeTimer = useRef<number | undefined>(undefined);

const cancelClose = () => window.clearTimeout(closeTimer.current);
const openNow = () => {
cancelClose();
setMode((current) => (current === "pinned" ? current : "hover"));
};
const closeSoon = () => {
cancelClose();
closeTimer.current = window.setTimeout(
() => setMode((current) => (current === "pinned" ? current : "closed")),
CLOSE_DELAY_MS,
);
};

useEffect(() => cancelClose, []);

// Going somewhere from it (Settings) is done with it, and so is focus mode sliding the sidebar
// away: portalled outside the sidebar, the card would stay behind, floating beside nothing.
// Adjusted while rendering rather than in an effect, so it never shows for a frame too long.
const { pathname } = useLocation();
const { isFocused } = useFocusMode();
const context = `${pathname}|${isFocused}`;
const [openedIn, setOpenedIn] = useState(context);
if (openedIn !== context) {
setOpenedIn(context);
setMode("closed");
}

// Escape and a click anywhere else close it, as for any popover.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
const focusWasInside = panelRef.current?.contains(document.activeElement) ?? false;
setMode("closed");
if (focusWasInside) triggerRef.current?.focus();
};
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (panelRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
setMode("closed");
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown);
};
}, [open]);

return (
<>
<button
ref={triggerRef}
type="button"
aria-label={label}
aria-expanded={open}
aria-controls={panelId}
// Hover only for a mouse: a touch "enter" is followed by a click, which would toggle it
// straight back shut.
onPointerEnter={(event) => event.pointerType === "mouse" && openNow()}
onPointerLeave={(event) => event.pointerType === "mouse" && closeSoon()}
onClick={(event) => {
cancelClose();
const opening = mode !== "pinned";
setMode(opening ? "pinned" : "closed");
// From the keyboard, into the card: portalled to the end of the page, it is not next
// in the tab order.
if (opening && event.detail === 0) {
window.requestAnimationFrame(() =>
panelRef.current
?.querySelector<HTMLElement>("a[href], button:not(:disabled)")
?.focus(),
);
}
}}
className={`flex h-11 w-11 items-center justify-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:ring-app-focus focus-visible:outline-none ${
open
? "border-app-brand-border bg-app-brand-soft"
: "border-app-border/70 bg-app-surface/70 hover:border-app-brand-border"
}`}
>
{trigger}
</button>

{createPortal(
<div
ref={panelRef}
id={panelId}
role="group"
aria-label={label}
inert={!open}
onPointerEnter={(event) => event.pointerType === "mouse" && openNow()}
onPointerLeave={(event) => event.pointerType === "mouse" && closeSoon()}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (next && !panelRef.current?.contains(next) && !triggerRef.current?.contains(next)) {
setMode("closed");
}
}}
// Slides out of the rail from its bottom-left corner, the way the footer card would
// have unfolded had there been room for it.
className={`fixed bottom-[16px] left-[calc(var(--app-sidebar-desktop-width,var(--app-sidebar-width))+10px)] z-40 hidden w-[262px] origin-bottom-left transition duration-200 ease-out motion-reduce:transition-none lg:block ${
open
? "translate-x-0 scale-100 opacity-100"
: "pointer-events-none -translate-x-3 scale-95 opacity-0"
}`}
>
{children}
</div>,
document.body,
)}
</>
);
}
92 changes: 88 additions & 4 deletions src/components/layout/SidebarLogo.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion, useReducedMotion, type TargetAndTransition, type Variants } from "framer-motion";
import { useRepeatClicks } from "../../features/easter-eggs/hooks/useRepeatClicks";
import { SIDEBAR_TOGGLE_SHORTCUT, shortcutChord } from "../../features/shortcuts";
import { ShortcutHint } from "../ui/ShortcutHint";
import { hoverSpringToken, logoHopSpringToken } from "../../styles/tokens";

const BADGE_SIZE = 44;

/**
* A click this soon after the previous one counts towards the gravity egg only, instead of
* folding the sidebar again: five quick clicks should drop the badge, not flap the sidebar five
* times while it falls.
*/
const TOGGLE_REPEAT_GUARD_MS = 600;

type SidebarLogoProps = {
className?: string;
/**
* Makes the badge fold and unfold the desktop sidebar, with a tooltip saying which. Left out
* on the mobile header, where the badge stays decoration with its egg.
*/
sidebarToggle?: { collapsed: boolean; onToggle: () => void };
};
const MARK_SIZE = 28;

/**
Expand Down Expand Up @@ -77,17 +96,56 @@ const NOZZLE_ORIGIN = { transformOrigin: "7px 17px", transformBox: "view-box" }
* in the corner of the eye stops reading as charm and starts reading as a
* fault.
*/
export function SidebarLogo({ className = "" }: { className?: string }) {
export function SidebarLogo({ className = "", sidebarToggle }: SidebarLogoProps) {
const prefersReducedMotion = useReducedMotion();
const badgeRef = useRef<HTMLDivElement>(null);
const lastClickAt = useRef(0);

// Where the toggle's tooltip sits, `null` while hidden. Beside the badge on the folded rail,
// like the entries' tooltips there; below it while open, where the name sits beside it. In a
// portal at a fixed position, so nothing in the sidebar clips it.
const [hintAt, setHintAt] = useState<{ x: number; y: number; side: "right" | "below" } | null>(
null,
);
// The badge moves whenever the sidebar folds or unfolds -- by a click here, Alt+S or the resize
// edge -- so a tooltip measured before would point at where it was. Cleared while rendering,
// so it never shows at the old spot for a frame; it comes back on the next hover.
const collapsed = sidebarToggle?.collapsed ?? false;
const [hintFor, setHintFor] = useState(collapsed);
if (hintFor !== collapsed) {
setHintFor(collapsed);
setHintAt(null);
}

// The gravity easter egg: five clicks drop the badge off its shelf.
// Reduced motion skips the animation entirely (pure motion has no honest
// static equivalent) — the counter still consumes, nothing plays.
const [dropPhase, setDropPhase] = useState<DropPhase>("idle");
const handleLogoClick = useRepeatClicks(5, () => {
const countEggClick = useRepeatClicks(5, () => {
if (!prefersReducedMotion && dropPhase === "idle") setDropPhase("falling");
});

const showHint = () => {
const rect = badgeRef.current?.getBoundingClientRect();
if (!sidebarToggle || !rect || dropPhase !== "idle") return;
setHintAt(
sidebarToggle.collapsed
? { x: rect.right + 10, y: rect.top + rect.height / 2, side: "right" }
: { x: rect.left, y: rect.bottom + 8, side: "below" },
);
};

const handleLogoClick = () => {
const now = Date.now();
const isQuickRepeat = now - lastClickAt.current < TOGGLE_REPEAT_GUARD_MS;
lastClickAt.current = now;
// The badge moves with the sidebar, so a tooltip measured before the click would point at
// where it was; it comes back on the next hover.
setHintAt(null);
if (sidebarToggle && !isQuickRepeat) sidebarToggle.onToggle();
countEggClick();
};

/** Walks the choreography forward as each beat's animation completes. */
const advanceDrop = () => {
setDropPhase((current) => {
Expand Down Expand Up @@ -162,6 +220,7 @@ export function SidebarLogo({ className = "" }: { className?: string }) {

return (
<motion.div
ref={badgeRef}
initial="rest"
animate={dropPhase === "idle" ? "rest" : DROP_PHASES[dropPhase]}
// Hover lift is suppressed while the gravity sequence plays so the
Expand All @@ -170,6 +229,8 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
variants={badgeVariants}
onAnimationComplete={advanceDrop}
onClick={handleLogoClick}
onPointerEnter={(event) => event.pointerType === "mouse" && showHint()}
onPointerLeave={() => setHintAt(null)}
data-drop-phase={dropPhase}
// The logo is deliberately decorative markup — the egg behind it is
// pure whimsy with no function, and making it tab-focusable on both
Expand All @@ -183,7 +244,9 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
// The pointer cursor stays: unlike a plain decorative wrapper this one
// does answer clicks (that is the whole egg), and it is the only hint
// anybody gets that the badge is worth touching. It is not a home link
// on any surface, so it promises no navigation.
// on any surface, so it promises no navigation. In the desktop sidebar it
// also folds and unfolds it (`sidebarToggle`); from the keyboard the same
// is Enter on the sidebar's resize edge.
style={{ width: BADGE_SIZE, height: BADGE_SIZE }}
className={`relative flex shrink-0 cursor-pointer items-center justify-center rounded-[12px] bg-app-brand shadow-lg select-none ${className}`}
>
Expand Down Expand Up @@ -234,6 +297,27 @@ export function SidebarLogo({ className = "" }: { className?: string }) {
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</g>
</motion.svg>

{hintAt &&
sidebarToggle &&
createPortal(
// Hidden from assistive technology: the badge is not reachable without a pointer, and
// the resize edge (Enter folds and unfolds) is their way to the same thing.
<span
aria-hidden="true"
style={{ left: hintAt.x, top: hintAt.y }}
className={`pointer-events-none fixed z-[70] flex items-center gap-2 rounded-lg border border-app-border bg-app-surface px-2.5 py-1.5 text-xs font-medium whitespace-nowrap text-app-text shadow-lg ${
hintAt.side === "right" ? "-translate-y-1/2" : ""
}`}
>
{sidebarToggle.collapsed ? "Expand sidebar" : "Collapse sidebar"}
<ShortcutHint
keys={shortcutChord(SIDEBAR_TOGGLE_SHORTCUT)}
className="border-app-border text-app-text-muted"
/>
</span>,
document.body,
)}
</motion.div>
);
}
Loading
Loading