diff --git a/src/components/App.tsx b/src/components/App.tsx index 0084213..c1d9b95 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -49,7 +49,7 @@ function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: () } - feed={} + feed={} />} discover={} /> ); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 877b39a..2ad627d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -96,11 +96,11 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
- {search} + {compact ? null : search}
{feed}
diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 41ac174..6ec6719 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -147,14 +147,17 @@ export interface DepthButtonProps extends NativeButton { label: string; sublabel?: string; icon?: ReactNode; + /** Raw leading content in place of the icon tile, for example a stack of avatars. */ + leading?: ReactNode; /** Trailing content, for example a chevron or the current theme name. */ trailing?: ReactNode; } -export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) { +export function DepthButton({ label, sublabel, icon, leading, trailing, className, type = "button", ...rest }: DepthButtonProps) { return ( + ))} + + ); +} + +export interface DiscoverSheetProps { + experts: DiscoverExpert[]; + onOpen: (panel: DiscoverPanel) => void; + onClose: () => void; +} + +export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) { + return ( + + {experts.length > 0 ? ( + onOpen("featured")} + leading={ + + } + trailing={} + /> + ) : null} + } trailing={} onClick={() => onOpen("trending")} /> + } trailing={} onClick={() => onOpen("search")} /> + Appearance + + + ); +} + +export interface DiscoverPanelSheetProps { + panel: DiscoverPanel; + experts: DiscoverExpert[]; + trending: DiscoverMinnect[]; + search?: ReactNode; + onClose: () => void; +} + +const TITLES: Record = { + featured: "Featured experts", + trending: "Trending minnects", + search: "Find experts", +}; + +/** One Discover panel as an 80% height sheet with a close button; reuses the desktop components. */ +export function DiscoverPanelSheet({ panel, experts, trending, search, onClose }: DiscoverPanelSheetProps) { + return ( + + {panel === "featured" ? : null} + {panel === "trending" ? : null} + {panel === "search" ? search : null} + + ); +} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index e311479..60f6211 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -6,7 +6,8 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client"; import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model"; import { PostError, publishAndConfirm } from "@/lib/post-client"; import { DeleteError, sendDelete } from "@/lib/delete-client"; -import type { DiscoverExpert } from "@/lib/discover"; +import type { ReactNode } from "react"; +import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover"; import type { ComposerProps } from "./Composer"; import { FeedView, type FeedStatus } from "./FeedView"; import { useCompactLayout } from "./layout-context"; @@ -18,6 +19,8 @@ export function Feed({ feedUserId, person, featuredExperts, + trendingMinnects, + searchPanel, }: { onSignedOut: () => void; feedUserId?: string | null; @@ -25,6 +28,9 @@ export function Feed({ person?: ComposerProps["person"]; /** Shown as a slide after the second post on phones. */ featuredExperts?: DiscoverExpert[]; + /** Phone Discover sheet: trending minnects and the Find experts panel. */ + trendingMinnects?: DiscoverMinnect[]; + searchPanel?: ReactNode; }) { const [posts, setPosts] = useState([]); const [status, setStatus] = useState("loading"); @@ -285,6 +291,8 @@ export function Feed({ onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }} featuredExperts={featuredExperts} + trendingMinnects={trendingMinnects} + searchPanel={searchPanel} hasMore={!!nextCursor} sentinelRef={sentinel} listRef={list} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index 854d069..777951d 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -2,7 +2,7 @@ import type { ReactNode, Ref } from "react"; import { canDelete, type CardPost } from "@/lib/feed-model"; -import type { DiscoverExpert } from "@/lib/discover"; +import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover"; import { Composer, type ComposerProps } from "./Composer"; import { GrowButton, IconButton } from "./Buttons"; import { PostCard } from "./PostCard"; @@ -42,6 +42,10 @@ export interface FeedViewProps { hasMore?: boolean; /** Phone layout only: experts for the featured slide after the second post. */ featuredExperts?: DiscoverExpert[]; + /** Phone layout only: trending minnects for the Discover sheet. */ + trendingMinnects?: DiscoverMinnect[]; + /** Phone layout only: the Find experts panel shown in the Discover sheet. */ + searchPanel?: ReactNode; /** Phone layout only: the slide on screen changed (for the dock). */ onCurrentChange?: (slide: CurrentSlide) => void; /** Phone layout only: the dock's comments button, for the post on screen. */ diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx index f68710f..86b1244 100644 --- a/src/components/MobileFeed.tsx +++ b/src/components/MobileFeed.tsx @@ -11,6 +11,7 @@ import { MobileDock } from "./MobileDock"; import { BottomSheet } from "./BottomSheet"; import { CommentsSection } from "./CommentsSection"; import { Composer } from "./Composer"; +import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet"; import type { DiscoverExpert } from "@/lib/discover"; import type { FeedViewProps } from "./FeedView"; @@ -281,8 +282,8 @@ 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); + // The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none. + const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | 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); @@ -443,7 +444,10 @@ export function MobileFeed(p: FeedViewProps) { setSheet({ kind: "composer" }); p.onOpenComposer?.(); }} - onOpenMore={p.onOpenMore} + onOpenMore={() => { + setSheet({ kind: "more" }); + p.onOpenMore?.(); + }} /> ) : null} {sheet?.kind === "comments" ? ( @@ -456,6 +460,12 @@ export function MobileFeed(p: FeedViewProps) { ) : null} + {sheet?.kind === "more" ? ( + setSheet({ kind: "panel", panel })} onClose={closeSheet} /> + ) : null} + {sheet?.kind === "panel" ? ( + + ) : null} ); } diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx index 364de9a..2390611 100644 --- a/src/dev/FeedPreview.tsx +++ b/src/dev/FeedPreview.tsx @@ -126,6 +126,18 @@ export function FeedPreview() { loadingMore={loadingMore || paging} hasMore={hasMore} featuredExperts={FIXTURE_DISCOVER.featuredExperts} + trendingMinnects={FIXTURE_DISCOVER.trendingMinnects} + searchPanel={ + + } sentinelRef={sentinel} listRef={listRef} loadMoreFailed={false} diff --git a/src/styles/sheet.css b/src/styles/sheet.css index f1f5e16..d6d6d27 100644 --- a/src/styles/sheet.css +++ b/src/styles/sheet.css @@ -88,6 +88,68 @@ .sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; } .sheet-composer .composer-att.composer-att-indent { margin-left: 0; } +/* Discover sheet (More): soft depth rows, then the Appearance switch. */ +.sheet-discover { gap: 14px; } +.sheet-discover .sheet-body { gap: 16px; padding: 4px 4px 4px; overflow-y: auto; } +.sheet-discover .btn-depth { position: relative; overflow: hidden; isolation: isolate; min-height: 72px; } +.sheet-discover .btn-depth-chev { width: 18px; height: 18px; flex: none; color: var(--ink-3); transition: color .3s; } +.sheet-discover .btn-depth:hover .btn-depth-chev, +.sheet-discover .btn-depth:focus-visible .btn-depth-chev { color: var(--on-accent); } +.depth-stack { display: flex; flex: none; } +.depth-stack .hex-avatar { margin-left: -10px; } +.depth-stack .hex-avatar:first-child { margin-left: 0; } +.sheet-sub { margin: 4px 0 -6px 4px; color: var(--ink-3); } +.seg-depth { + display: flex; + gap: 4px; + padding: 4px; + border-radius: var(--r-card); + background: var(--bg); + box-shadow: inset 4px 4px 10px var(--neu-d), inset -4px -4px 10px var(--neu-l); +} +.seg-depth button { + appearance: none; + border: 0; + cursor: pointer; + flex: 1; + height: 44px; + border-radius: var(--r-ctl); + background: transparent; + color: var(--ink-3); + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background-color .25s, color .25s, box-shadow .25s; +} +.seg-depth button svg { width: 18px; height: 18px; } +.seg-depth button.on { + background: var(--bg); + color: var(--ink); + box-shadow: 4px 4px 10px var(--neu-d), -4px -4px 10px var(--neu-l); +} +.seg-depth button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; } + +/* Panel sheets: 80% height, the desktop panels without their tile chrome or repeated heading. */ +.sheet-panel { height: 80%; max-height: 80%; } +.sheet-panel .sheet-body { flex: 1; overflow: hidden; } +.sheet-panel .sheet-body > .tile { + flex: 1; + min-height: 0; + margin: 0; + padding: 0; + background: transparent; + box-shadow: none; + border-radius: 0; +} +.sheet-panel .sheet-body > .tile > .panel-head { display: none; } +.sheet-panel .search-results, +.sheet-panel .trend-list { flex: 1 1 0; min-height: 0; overflow-y: auto; } +.sheet-panel .search-field input { font-size: 16px; } +.sheet-panel-featured .sheet-body { overflow-y: auto; align-items: center; justify-content: center; } + @media (prefers-reduced-motion: reduce) { .sheet, .sheet-bg { animation: none !important; } } diff --git a/tests/discover-sheet.test.ts b/tests/discover-sheet.test.ts new file mode 100644 index 0000000..fbab132 --- /dev/null +++ b/tests/discover-sheet.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { DiscoverPanelSheet, DiscoverSheet, featuredSubtitle, PANEL_CAROUSEL_SIZE } from "@/components/DiscoverSheet"; +import { FIXTURE_DISCOVER } from "@/dev/fixtures"; + +const experts = FIXTURE_DISCOVER.featuredExperts; +const html = (el: ReturnType) => renderToStaticMarkup(el); + +describe("featuredSubtitle", () => { + it("names two experts and counts the rest", () => { + const list = [{ displayName: "A" }, { displayName: "B" }, { displayName: "C" }, { displayName: "D" }]; + expect(featuredSubtitle(list)).toBe("A, B and 2 more"); + expect(featuredSubtitle(list.slice(0, 3))).toBe("A, B and 1 more"); + }); + it("reads plainly for short lists", () => { + expect(featuredSubtitle([])).toBe(""); + expect(featuredSubtitle([{ displayName: "A" }])).toBe("A"); + expect(featuredSubtitle([{ displayName: "A" }, { displayName: "B" }])).toBe("A and B"); + }); +}); + +describe("Discover sheet", () => { + const out = html(createElement(DiscoverSheet, { experts, onOpen: () => {}, onClose: () => {} })); + it("has three depth rows and the appearance switch", () => { + expect(out).toContain("Discover"); + expect((out.match(/btn-depth"/g) ?? []).length).toBe(3); + expect(out).toContain("Featured experts"); + expect(out).toContain("Trending minnects"); + expect(out).toContain("Find experts"); + expect(out).toContain('aria-label="Appearance"'); + expect(out).toContain("Dark"); + expect(out).toContain("Light"); + expect(out).toContain(featuredSubtitle(experts)); + }); +}); + +describe("Discover panel sheets", () => { + it("shows the featured carousel at 150px", () => { + expect(PANEL_CAROUSEL_SIZE).toBe(150); + const out = html(createElement(DiscoverPanelSheet, { panel: "featured", experts, trending: [], onClose: () => {} })); + expect(out).toContain("--cfw:150px"); + expect(out).toContain('aria-label="Close"'); + }); + it("is 80% high", () => { + const css = readFileSync("src/styles/sheet.css", "utf8"); + expect(css).toMatch(/\.sheet-panel\s*\{[^}]*height:\s*80%/); + }); +});