diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea58f0d..0842ede 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,6 +15,7 @@ import "../styles/feed.css"; import "../styles/trending.css"; import "../styles/search.css"; import "../styles/mobile.css"; +import "../styles/sheet.css"; import "../styles/legacy.css"; const manrope = Manrope({ diff --git a/src/components/BottomSheet.tsx b/src/components/BottomSheet.tsx new file mode 100644 index 0000000..4ac05a9 --- /dev/null +++ b/src/components/BottomSheet.tsx @@ -0,0 +1,90 @@ +"use client"; + +// Bottom sheet (spec "Sheets"): slides up over a dimmed, blurred backdrop with a +// grab handle, a serif title and a close button. Reused by the comments, +// composer and Discover sheets. Styles live in src/styles/sheet.css. +import { useEffect, useId, useRef, type ReactNode } from "react"; + +const FOCUSABLE = 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])'; + +export interface BottomSheetProps { + title: string; + onClose: () => void; + children: ReactNode; + /** Extra class on the sheet, for per-sheet sizing. */ + className?: string; +} + +export function BottomSheet({ title, onClose, children, className }: BottomSheetProps) { + const sheet = useRef(null); + const titleId = useId(); + const closeRef = useRef(onClose); + closeRef.current = onClose; + + useEffect(() => { + const el = sheet.current; + if (!el) return; + const opener = document.activeElement as HTMLElement | null; + // Focus moves into the sheet: its first field, else the sheet itself. + const first = el.querySelector("input, textarea") ?? el.querySelector(FOCUSABLE); + (first ?? el).focus({ preventScroll: true }); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { + // An open emoji popover closes first; it handles Escape itself. + if (el?.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]')) return; + e.preventDefault(); + closeRef.current(); + return; + } + if (e.key !== "Tab" || !el) return; + const items = [...el.querySelectorAll(FOCUSABLE)].filter((n) => n.offsetParent !== null || n === document.activeElement); + if (!items.length) return; + const a = items[0]; + const z = items[items.length - 1]; + if (e.shiftKey && (document.activeElement === a || document.activeElement === el)) { + e.preventDefault(); + z.focus(); + } else if (!e.shiftKey && document.activeElement === z) { + e.preventDefault(); + a.focus(); + } + } + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + // Focus returns to the opener (when it is still on the page). + if (opener && opener.isConnected) opener.focus({ preventScroll: true }); + }; + }, []); + + return ( + <> + + +
{children}
+ + + ); +} diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index f6c581c..f68710f 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, type MouseEvent, type Ref } from "react"; +import { useCallback, useEffect, useRef, useState, type MouseEvent, type Ref } from "react"; import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model"; import { GrowButton } from "./Buttons"; import { HexAvatar } from "./HexAvatar"; @@ -8,6 +8,9 @@ import { PlayIcon, PostMenu } from "./PostCard"; import { VideoPlayer } from "./VideoPlayer"; import { FeaturedCarousel } from "./FeaturedCarousel"; import { MobileDock } from "./MobileDock"; +import { BottomSheet } from "./BottomSheet"; +import { CommentsSection } from "./CommentsSection"; +import { Composer } from "./Composer"; import type { DiscoverExpert } from "@/lib/discover"; import type { FeedViewProps } from "./FeedView"; @@ -278,6 +281,17 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo export function MobileFeed(p: FeedViewProps) { const reel = useRef(null); const [current, setCurrent] = useState(0); + // The open sheet: the comments of one post, the composer, or none. + const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | null>(null); + const closeSheet = useCallback(() => setSheet(null), []); + // A successful post closes the composer: publishing ends with no error and an emptied draft. + const wasBusy = useRef(false); + const composerBusy = !!p.composer?.busy; + const composerClear = !!p.composer && !p.composer.error && !p.composer.value && !p.composer.file; + useEffect(() => { + if (wasBusy.current && !composerBusy && composerClear) setSheet((s) => (s?.kind === "composer" ? null : s)); + wasBusy.current = composerBusy; + }, [composerBusy, composerClear]); const onCurrentChange = p.onCurrentChange; const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore)); const experts = p.featuredExperts ?? []; @@ -421,11 +435,27 @@ export function MobileFeed(p: FeedViewProps) { slide={{ index, kind, post: currentPost }} onToggleLike={p.onToggleLike} likeBusy={currentPost ? p.likeBusy?.has(currentPost.key) : false} - onOpenComments={p.onOpenComments} - onOpenComposer={p.onOpenComposer} + onOpenComments={(postId) => { + setSheet({ kind: "comments", postId }); + p.onOpenComments?.(postId); + }} + onOpenComposer={() => { + setSheet({ kind: "composer" }); + p.onOpenComposer?.(); + }} onOpenMore={p.onOpenMore} /> ) : null} + {sheet?.kind === "comments" ? ( + + + + ) : null} + {sheet?.kind === "composer" && p.composer ? ( + + + + ) : null} ); } diff --git a/src/styles/sheet.css b/src/styles/sheet.css new file mode 100644 index 0000000..bde550b --- /dev/null +++ b/src/styles/sheet.css @@ -0,0 +1,93 @@ +/* + * Bottom sheets (spec "Sheets"). Ported from the mockup's .psheet, .grab, + * .psheet-h and .ph-bg rules, plus the `.mx.sheet .cmp` composer rules. Tokens + * only. The sheet sits inside the phone feed (.reel-feed), over the dock. + */ +@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } } +@keyframes sheet-fade { from { opacity: 0; } to { opacity: 1; } } + +.sheet-bg { + position: absolute; + inset: 0; + z-index: 55; + appearance: none; + border: 0; + padding: 0; + cursor: pointer; + background: color-mix(in srgb, var(--bg), transparent 50%); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + animation: sheet-fade .3s both; +} +.sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 60; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 78%; + padding: 10px 14px calc(24px + env(safe-area-inset-bottom, 0px)); + background: var(--bg); + color: var(--ink); + border-radius: 26px 26px 0 0; + box-shadow: 0 -20px 50px -10px color-mix(in srgb, var(--neu-d), transparent 45%), inset 0 1px 0 var(--line); + animation: sheet-up .45s cubic-bezier(.2,.9,.25,1) both; + outline: none; +} +.sheet-grab { + flex: none; + width: 40px; + height: 5px; + margin: 0 auto 4px; + border-radius: 3px; + background: var(--line-2); +} +.sheet-h { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.sheet-h h2 { margin: 0; font-size: 25px; line-height: 1.2; } +.sheet-x { + appearance: none; + flex: none; + width: 44px; + height: 44px; + margin: 0; + padding: 0; + border: 0; + border-radius: var(--r-ctl); + display: grid; + place-items: center; + background: transparent; + color: var(--ink-2); + cursor: pointer; + transition: background-color .2s, color .2s; +} +.sheet-x svg { width: 20px; height: 20px; } +@media (hover: hover) { .sheet-x:hover { background: var(--tile-2); color: var(--ink); } } +.sheet-x:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } +.sheet-body { min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; } + +/* Comments sheet: the shared comments panel without its inline-card chrome. */ +.sheet-comments .post-cm { margin: 0; padding: 0; border-top: 0; animation: none; flex: 1; min-height: 0; } +.sheet-comments .post-cm-list { overflow-y: auto; min-height: 60px; } +.sheet-comments .post-cm-form { flex: none; } +.sheet-comments .post-cm-form input { font-size: 16px; } + +/* Composer sheet: the desktop composer, borderless, with a 150px text area. */ +.sheet-composer { max-height: 92%; } +.sheet-composer .sheet-body { overflow: visible; } +.sheet-composer .composer, +.sheet-composer .composer.composer-open { margin: 0; padding: 0; background: transparent; box-shadow: none; } +.sheet-composer .composer textarea, +.sheet-composer .composer.composer-open textarea { height: 150px; font-size: 16px; } +.sheet-composer .composer-bar { flex-wrap: wrap; } +.sheet-composer .composer-kbd { display: none; } +/* The popover anchors to the whole composer (not the emoji button) so it fits a phone: full width, just above the toolbar. */ +.sheet-composer .composer-emoji-wrap { position: static; } +.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; } +.sheet-composer .composer-att.composer-att-indent { margin-left: 0; } + +@media (prefers-reduced-motion: reduce) { + .sheet, .sheet-bg { animation: none !important; } +} diff --git a/tests/bottom-sheet.test.ts b/tests/bottom-sheet.test.ts new file mode 100644 index 0000000..7261808 --- /dev/null +++ b/tests/bottom-sheet.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { createElement, type FC } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { BottomSheet } from "@/components/BottomSheet"; + +const css = readFileSync("src/styles/sheet.css", "utf8"); +const feed = readFileSync("src/components/MobileFeed.tsx", "utf8"); + +describe("BottomSheet", () => { + const html = renderToStaticMarkup(createElement(BottomSheet as FC<{ title: string; onClose: () => void }>, { title: "New post", onClose: () => {} }, createElement("p", null, "body"))); + it("is a modal dialog named by its serif title, with a grab handle, close buttons and a backdrop", () => { + expect(html).toContain('role="dialog"'); + expect(html).toContain('aria-modal="true"'); + expect(html).toMatch(/aria-labelledby="([^"]+)"[\s\S]*

]*type-title[^>]*>New post<\/h2>/); + expect(html).toContain("sheet-grab"); + expect(html).toContain('class="sheet-bg"'); + expect(html.match(/aria-label="Close"/g)?.length).toBe(2); + expect(html).toContain("

body

"); + }); + it("slides up over 450ms and turns the motion off under reduced motion", () => { + expect(css).toMatch(/animation: sheet-up \.45s/); + expect(css).toMatch(/prefers-reduced-motion: reduce[\s\S]*animation: none/); + expect(css).toMatch(/backdrop-filter: blur\(3px\)/); + }); + it("gives the composer sheet a 150px text area", () => { + expect(css).toMatch(/\.sheet-composer \.composer textarea[\s\S]*height: 150px/); + }); +}); + +describe("phone feed sheets", () => { + it("opens the shared comments section and composer from the dock", () => { + expect(feed).toContain('"); + }); +});