Feed browser: Mobile immersive feed #4
@@ -49,7 +49,7 @@ function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: ()
|
|||||||
<AppShell
|
<AppShell
|
||||||
person={person}
|
person={person}
|
||||||
search={<ExpertSearch />}
|
search={<ExpertSearch />}
|
||||||
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} />}
|
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} trendingMinnects={discover?.trendingMinnects} searchPanel={<ExpertSearch />} />}
|
||||||
discover={<DiscoverRailsView data={discover} />}
|
discover={<DiscoverRailsView data={discover} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,11 +96,11 @@ export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
|||||||
<main className="bento">
|
<main className="bento">
|
||||||
<div className="bento-col bento-left">
|
<div className="bento-col bento-left">
|
||||||
<AccountPanel person={person} />
|
<AccountPanel person={person} />
|
||||||
{search}
|
{compact ? null : search}
|
||||||
</div>
|
</div>
|
||||||
<div className="bento-col bento-center">{feed}</div>
|
<div className="bento-col bento-center">{feed}</div>
|
||||||
<aside className="bento-col bento-right" aria-label="Discover">
|
<aside className="bento-col bento-right" aria-label="Discover">
|
||||||
{discover}
|
{compact ? null : discover}
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -147,14 +147,17 @@ export interface DepthButtonProps extends NativeButton {
|
|||||||
label: string;
|
label: string;
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
icon?: ReactNode;
|
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 content, for example a chevron or the current theme name. */
|
||||||
trailing?: ReactNode;
|
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 (
|
return (
|
||||||
<button {...rest} type={type} className={cx("btn-depth", className)}>
|
<button {...rest} type={type} className={cx("btn-depth", className)}>
|
||||||
{icon ? (
|
{leading ?? null}
|
||||||
|
{!leading && icon ? (
|
||||||
<span className="btn-depth-icon" aria-hidden="true">
|
<span className="btn-depth-icon" aria-hidden="true">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Mobile Discover sheet (spec "Sheets"): three soft depth rows that open the
|
||||||
|
// Featured experts, Trending minnects and Find experts panels as 80% height
|
||||||
|
// sheets, then the Dark/Light appearance switch. Styles: src/styles/sheet.css.
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||||
|
import { useTheme } from "@/lib/use-theme";
|
||||||
|
import type { Theme } from "@/lib/theme";
|
||||||
|
import { BottomSheet } from "./BottomSheet";
|
||||||
|
import { DepthButton } from "./Buttons";
|
||||||
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
|
import { HexAvatar } from "./HexAvatar";
|
||||||
|
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
||||||
|
|
||||||
|
export type DiscoverPanel = "featured" | "trending" | "search";
|
||||||
|
|
||||||
|
/** Width of the center photo in the featured panel sheet. */
|
||||||
|
export const PANEL_CAROUSEL_SIZE = 150;
|
||||||
|
|
||||||
|
/** "Name1, Name2 and N more" for the Featured experts row; shorter lists read plainly. */
|
||||||
|
export function featuredSubtitle(experts: Pick<DiscoverExpert, "displayName">[]): string {
|
||||||
|
const names = experts.map((e) => e.displayName || "Expert");
|
||||||
|
if (names.length === 0) return "";
|
||||||
|
if (names.length === 1) return names[0];
|
||||||
|
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||||
|
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chevron() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="btn-depth-chev">
|
||||||
|
<path d="m9 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||||
|
|
||||||
|
function FlameIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon}>
|
||||||
|
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon}>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m20 20-3.5-3.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon} aria-hidden="true">
|
||||||
|
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon} aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Dark/Light switch in the soft depth style; the choice persists through useTheme. */
|
||||||
|
export function AppearanceSwitch() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const options: { value: Theme; label: string; icon: ReactNode }[] = [
|
||||||
|
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
|
||||||
|
{ value: "light", label: "Light", icon: <SunIcon /> },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="seg-depth" role="group" aria-label="Appearance">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.value} type="button" className={theme === o.value ? "on" : undefined} aria-pressed={theme === o.value} onClick={() => setTheme(o.value)}>
|
||||||
|
{o.icon}
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverSheetProps {
|
||||||
|
experts: DiscoverExpert[];
|
||||||
|
onOpen: (panel: DiscoverPanel) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) {
|
||||||
|
return (
|
||||||
|
<BottomSheet title="Discover" onClose={onClose} className="sheet-discover">
|
||||||
|
{experts.length > 0 ? (
|
||||||
|
<DepthButton
|
||||||
|
label="Featured experts"
|
||||||
|
sublabel={featuredSubtitle(experts)}
|
||||||
|
onClick={() => onOpen("featured")}
|
||||||
|
leading={
|
||||||
|
<span className="depth-stack" aria-hidden="true">
|
||||||
|
{experts.slice(0, 3).map((e) => (
|
||||||
|
<HexAvatar key={String(e.id)} name={e.displayName || "Expert"} pictureUrl={e.pictureUrl} size="sm" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
trailing={<Chevron />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DepthButton label="Trending minnects" sublabel="Answers people are watching" icon={<FlameIcon />} trailing={<Chevron />} onClick={() => onOpen("trending")} />
|
||||||
|
<DepthButton label="Find experts" sublabel="Search by name, topic or category" icon={<SearchIcon />} trailing={<Chevron />} onClick={() => onOpen("search")} />
|
||||||
|
<span className="type-eyebrow sheet-sub">Appearance</span>
|
||||||
|
<AppearanceSwitch />
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverPanelSheetProps {
|
||||||
|
panel: DiscoverPanel;
|
||||||
|
experts: DiscoverExpert[];
|
||||||
|
trending: DiscoverMinnect[];
|
||||||
|
search?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLES: Record<DiscoverPanel, string> = {
|
||||||
|
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 (
|
||||||
|
<BottomSheet title={TITLES[panel]} onClose={onClose} className={`sheet-panel sheet-panel-${panel}`}>
|
||||||
|
{panel === "featured" ? <FeaturedCarousel experts={experts} size={PANEL_CAROUSEL_SIZE} variant="slide" /> : null}
|
||||||
|
{panel === "trending" ? <TrendingMinnectsRail minnects={trending} /> : null}
|
||||||
|
{panel === "search" ? search : null}
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
|||||||
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||||
import { DeleteError, sendDelete } from "@/lib/delete-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 type { ComposerProps } from "./Composer";
|
||||||
import { FeedView, type FeedStatus } from "./FeedView";
|
import { FeedView, type FeedStatus } from "./FeedView";
|
||||||
import { useCompactLayout } from "./layout-context";
|
import { useCompactLayout } from "./layout-context";
|
||||||
@@ -18,6 +19,8 @@ export function Feed({
|
|||||||
feedUserId,
|
feedUserId,
|
||||||
person,
|
person,
|
||||||
featuredExperts,
|
featuredExperts,
|
||||||
|
trendingMinnects,
|
||||||
|
searchPanel,
|
||||||
}: {
|
}: {
|
||||||
onSignedOut: () => void;
|
onSignedOut: () => void;
|
||||||
feedUserId?: string | null;
|
feedUserId?: string | null;
|
||||||
@@ -25,6 +28,9 @@ export function Feed({
|
|||||||
person?: ComposerProps["person"];
|
person?: ComposerProps["person"];
|
||||||
/** Shown as a slide after the second post on phones. */
|
/** Shown as a slide after the second post on phones. */
|
||||||
featuredExperts?: DiscoverExpert[];
|
featuredExperts?: DiscoverExpert[];
|
||||||
|
/** Phone Discover sheet: trending minnects and the Find experts panel. */
|
||||||
|
trendingMinnects?: DiscoverMinnect[];
|
||||||
|
searchPanel?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
@@ -285,6 +291,8 @@ export function Feed({
|
|||||||
onSignedOut={onSignedOut}
|
onSignedOut={onSignedOut}
|
||||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||||
featuredExperts={featuredExperts}
|
featuredExperts={featuredExperts}
|
||||||
|
trendingMinnects={trendingMinnects}
|
||||||
|
searchPanel={searchPanel}
|
||||||
hasMore={!!nextCursor}
|
hasMore={!!nextCursor}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
listRef={list}
|
listRef={list}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
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 { Composer, type ComposerProps } from "./Composer";
|
||||||
import { GrowButton, IconButton } from "./Buttons";
|
import { GrowButton, IconButton } from "./Buttons";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
@@ -42,6 +42,10 @@ export interface FeedViewProps {
|
|||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
/** Phone layout only: experts for the featured slide after the second post. */
|
/** Phone layout only: experts for the featured slide after the second post. */
|
||||||
featuredExperts?: DiscoverExpert[];
|
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). */
|
/** Phone layout only: the slide on screen changed (for the dock). */
|
||||||
onCurrentChange?: (slide: CurrentSlide) => void;
|
onCurrentChange?: (slide: CurrentSlide) => void;
|
||||||
/** Phone layout only: the dock's comments button, for the post on screen. */
|
/** Phone layout only: the dock's comments button, for the post on screen. */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MobileDock } from "./MobileDock";
|
|||||||
import { BottomSheet } from "./BottomSheet";
|
import { BottomSheet } from "./BottomSheet";
|
||||||
import { CommentsSection } from "./CommentsSection";
|
import { CommentsSection } from "./CommentsSection";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
|
import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet";
|
||||||
import type { DiscoverExpert } from "@/lib/discover";
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
import type { FeedViewProps } from "./FeedView";
|
import type { FeedViewProps } from "./FeedView";
|
||||||
|
|
||||||
@@ -281,8 +282,8 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo
|
|||||||
export function MobileFeed(p: FeedViewProps) {
|
export function MobileFeed(p: FeedViewProps) {
|
||||||
const reel = useRef<HTMLDivElement | null>(null);
|
const reel = useRef<HTMLDivElement | null>(null);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
// The open sheet: the comments of one post, the composer, or none.
|
// 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" } | null>(null);
|
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
|
||||||
const closeSheet = useCallback(() => setSheet(null), []);
|
const closeSheet = useCallback(() => setSheet(null), []);
|
||||||
// A successful post closes the composer: publishing ends with no error and an emptied draft.
|
// A successful post closes the composer: publishing ends with no error and an emptied draft.
|
||||||
const wasBusy = useRef(false);
|
const wasBusy = useRef(false);
|
||||||
@@ -443,7 +444,10 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
setSheet({ kind: "composer" });
|
setSheet({ kind: "composer" });
|
||||||
p.onOpenComposer?.();
|
p.onOpenComposer?.();
|
||||||
}}
|
}}
|
||||||
onOpenMore={p.onOpenMore}
|
onOpenMore={() => {
|
||||||
|
setSheet({ kind: "more" });
|
||||||
|
p.onOpenMore?.();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{sheet?.kind === "comments" ? (
|
{sheet?.kind === "comments" ? (
|
||||||
@@ -456,6 +460,12 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
<Composer {...p.composer} />
|
<Composer {...p.composer} />
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
) : null}
|
) : null}
|
||||||
|
{sheet?.kind === "more" ? (
|
||||||
|
<DiscoverSheet experts={experts} onOpen={(panel) => setSheet({ kind: "panel", panel })} onClose={closeSheet} />
|
||||||
|
) : null}
|
||||||
|
{sheet?.kind === "panel" ? (
|
||||||
|
<DiscoverPanelSheet panel={sheet.panel} experts={experts} trending={p.trendingMinnects ?? []} search={p.searchPanel} onClose={closeSheet} />
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ export function FeedPreview() {
|
|||||||
loadingMore={loadingMore || paging}
|
loadingMore={loadingMore || paging}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
|
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
|
||||||
|
trendingMinnects={FIXTURE_DISCOVER.trendingMinnects}
|
||||||
|
searchPanel={
|
||||||
|
<ExpertSearchView
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
categories={FIXTURE_CATEGORIES}
|
||||||
|
activeCategory={category}
|
||||||
|
onCategory={setCategory}
|
||||||
|
status="ready"
|
||||||
|
results={results}
|
||||||
|
/>
|
||||||
|
}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
listRef={listRef}
|
listRef={listRef}
|
||||||
loadMoreFailed={false}
|
loadMoreFailed={false}
|
||||||
|
|||||||
@@ -88,6 +88,68 @@
|
|||||||
.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; }
|
.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; }
|
||||||
.sheet-composer .composer-att.composer-att-indent { margin-left: 0; }
|
.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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.sheet, .sheet-bg { animation: none !important; }
|
.sheet, .sheet-bg { animation: none !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof createElement>) => 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%/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user