diff --git a/src/components/common/CreatorBio.tsx b/src/components/common/CreatorBio.tsx
index 47e2924..ae3f60c 100644
--- a/src/components/common/CreatorBio.tsx
+++ b/src/components/common/CreatorBio.tsx
@@ -1,3 +1,4 @@
+import { useId, useState } from 'react';
import { cn } from '@/lib/utils';
import { lineClampClassFor } from '@/utils/lineClamp.utils';
@@ -27,12 +28,41 @@ interface CreatorBioProps {
* uniform across varying bio lengths. Short bios are unaffected.
*/
maxLines?: number | null;
+ /**
+ * Enable auto-collapse with an expand toggle (#315). Only meaningful
+ * for the `profile` variant — on the card the clamp already keeps
+ * layouts compact. When true and the bio exceeds `collapsedMaxLines`,
+ * the bio renders in a collapsed state and a focusable "Show more" /
+ * "Show less" button toggles to the full bio.
+ *
+ * The auto-collapse threshold is heuristic: a character count proxy
+ * for the line count, since we don't have access to the rendered DOM
+ * height during render. Bios below the threshold render without the
+ * toggle (acceptance criterion: short bios are unaffected).
+ */
+ collapsible?: boolean;
+ /**
+ * Line count to clamp to in the collapsed state (default `4`).
+ * Ignored when `collapsible` is false.
+ */
+ collapsedMaxLines?: number;
+ /**
+ * Character count above which a `profile`-variant bio is treated as
+ * "long enough to warrant a toggle" (default `200`). Below this we
+ * render the full bio with no toggle — the auto-collapse is only
+ * useful for bios that would actually push other content off-screen.
+ */
+ collapseThresholdChars?: number;
className?: string;
}
const DEFAULT_FALLBACK = "This creator hasn't shared a bio yet.";
/** Default maximum bio lines on the card. */
const DEFAULT_CARD_MAX_LINES = 3;
+/** Default maximum lines in the collapsed profile state. */
+const DEFAULT_COLLAPSED_LINES = 4;
+/** Default char count above which the collapsible toggle is rendered. */
+const DEFAULT_COLLAPSE_THRESHOLD_CHARS = 200;
const variantClasses: Record<'card' | 'profile', { value: string; fallback: string }> = {
card: {
@@ -58,10 +88,15 @@ const CreatorBio: React.FC = ({
allowEmpty = false,
isOnboardingPending = false,
maxLines,
+ collapsible = false,
+ collapsedMaxLines = DEFAULT_COLLAPSED_LINES,
+ collapseThresholdChars = DEFAULT_COLLAPSE_THRESHOLD_CHARS,
className,
}) => {
const trimmed = bio?.trim();
const styles = variantClasses[variant];
+ const bioId = useId();
+ const [expanded, setExpanded] = useState(false);
if (!trimmed) {
if (allowEmpty) {
@@ -85,17 +120,32 @@ const CreatorBio: React.FC = ({
);
}
+ // `collapsible` only applies to the `profile` variant; the card already
+ // has its own clamp via `maxLines`. Long enough = above the char
+ // threshold (acceptance: short bios are unaffected).
+ const shouldOfferCollapse =
+ collapsible &&
+ variant === 'profile' &&
+ trimmed.length > collapseThresholdChars;
+
// Card defaults to a 3-line clamp; explicit null disables it. Profile
- // variant ignores the prop so the full bio stays visible on the detail
- // page.
- const effectiveMaxLines =
- variant === 'card' && maxLines === undefined
+ // variant ignores `maxLines` unless `collapsible` is engaged, in which
+ // case we clamp to `collapsedMaxLines` while collapsed.
+ const effectiveMaxLines = shouldOfferCollapse
+ ? expanded
+ ? null
+ : collapsedMaxLines
+ : variant === 'card' && maxLines === undefined
? DEFAULT_CARD_MAX_LINES
: maxLines;
- const clampClass = lineClampClassFor(variant, effectiveMaxLines);
+ const clampVariant: 'card' | 'profile' = shouldOfferCollapse
+ ? 'card'
+ : variant;
+ const clampClass = lineClampClassFor(clampVariant, effectiveMaxLines);
- return (
+ const bioParagraph = (
= ({
{trimmed}
);
+
+ if (!shouldOfferCollapse) {
+ return bioParagraph;
+ }
+
+ return (
+
+ {bioParagraph}
+ setExpanded(prev => !prev)}
+ className="font-jakarta text-xs font-semibold text-amber-300/85 underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/55 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-950 rounded-sm"
+ >
+ {expanded ? 'Show less' : 'Show more'}
+
+
+ );
};
export default CreatorBio;
diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx
index a3bf453..880de2e 100644
--- a/src/components/common/CreatorProfileHeader.tsx
+++ b/src/components/common/CreatorProfileHeader.tsx
@@ -104,7 +104,14 @@ const CreatorProfileHeader: React.FC = ({
>
{displayHandle || `@${handle}`}
-
+ {/* #315: profile bio auto-collapses with a Show more / less
+ toggle once long enough. Short bios render unchanged. */}
+
diff --git a/src/components/common/__tests__/CreatorBio.test.tsx b/src/components/common/__tests__/CreatorBio.test.tsx
index 07f3ac6..1aa4b61 100644
--- a/src/components/common/__tests__/CreatorBio.test.tsx
+++ b/src/components/common/__tests__/CreatorBio.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import CreatorBio from '@/components/common/CreatorBio';
import { lineClampClassFor } from '@/utils/lineClamp.utils';
@@ -126,3 +126,70 @@ describe('CreatorBio clamp + onboarding integration', () => {
expect(screen.queryByText(/setting up their profile/i)).toBeNull();
});
});
+
+// ---------------------------------------------------------------------------
+// #315 — auto-collapse + expand toggle on the profile variant
+// ---------------------------------------------------------------------------
+
+describe('CreatorBio collapsible (#315)', () => {
+ const LONG = 'X'.repeat(250); // > default 200-char threshold
+
+ it('does not render a toggle for a short bio even when collapsible is true', () => {
+ render( );
+ expect(screen.queryByRole('button')).toBeNull();
+ // And the paragraph is plain — no line-clamp class.
+ expect(screen.getByText('Short bio.').className).not.toMatch(/line-clamp/);
+ });
+
+ it('does not engage collapse on the card variant — that variant clamps already', () => {
+ render( );
+ expect(screen.queryByRole('button')).toBeNull();
+ });
+
+ it('clamps a long profile bio and renders a Show more button', () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Show more' });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ const paragraph = screen.getByText(LONG);
+ // Collapsed → some line-clamp utility is applied.
+ expect(paragraph.className).toMatch(/line-clamp/);
+ // aria-controls points at the bio paragraph.
+ const controls = button.getAttribute('aria-controls');
+ expect(controls).toBeTruthy();
+ expect(paragraph.id).toBe(controls);
+ });
+
+ it('toggles to Show less and removes the clamp when expanded', () => {
+ render( );
+ const button = screen.getByRole('button', { name: 'Show more' });
+ fireEvent.click(button);
+ expect(
+ screen.getByRole('button', { name: 'Show less' })
+ ).toHaveAttribute('aria-expanded', 'true');
+ expect(screen.getByText(LONG).className).not.toMatch(/line-clamp/);
+ });
+
+ it('respects an explicit collapseThresholdChars override', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: 'Show more' })).toBeInTheDocument();
+ });
+
+ it('clamps to collapsedMaxLines when supplied', () => {
+ render(
+
+ );
+ expect(screen.getByText(LONG).className).toMatch(/\bline-clamp-2\b/);
+ });
+});
diff --git a/src/components/ui/bottom-sheet.test.tsx b/src/components/ui/bottom-sheet.test.tsx
new file mode 100644
index 0000000..6dc5db7
--- /dev/null
+++ b/src/components/ui/bottom-sheet.test.tsx
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import {
+ BottomSheet,
+ BottomSheetContent,
+ BottomSheetHandle,
+ BottomSheetTitle,
+} from './bottom-sheet';
+
+function renderSheet(props?: {
+ dismissThresholdPx?: number;
+ enableDrag?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const onOpenChange = props?.onOpenChange ?? vi.fn();
+ render(
+
+
+
+ Mobile actions
+ Inner action
+
+
+ );
+ return { onOpenChange };
+}
+
+describe('BottomSheet (#314)', () => {
+ it('renders with a drag handle and a default close button', () => {
+ renderSheet();
+ expect(screen.getByTestId('bottom-sheet-handle')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Close panel' })).toBeInTheDocument();
+ // The Title is announced.
+ expect(screen.getByText('Mobile actions')).toBeInTheDocument();
+ });
+
+ it('close button dismisses the sheet via Radix onOpenChange', () => {
+ const { onOpenChange } = renderSheet();
+ screen.getByRole('button', { name: 'Close panel' }).click();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('drag past the threshold dismisses the sheet', () => {
+ const { onOpenChange } = renderSheet({ dismissThresholdPx: 50 });
+ const handle = screen.getByTestId('bottom-sheet-handle');
+
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 100, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 220 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 220 });
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('short drag below the threshold does NOT dismiss', () => {
+ const { onOpenChange } = renderSheet({ dismissThresholdPx: 150 });
+ const handle = screen.getByTestId('bottom-sheet-handle');
+
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 100, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 130 }); // 30px
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 130 });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('upward drag is clamped and never dismisses', () => {
+ const { onOpenChange } = renderSheet({ dismissThresholdPx: 30 });
+ const handle = screen.getByTestId('bottom-sheet-handle');
+
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 200, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 80 }); // -120
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 80 });
+
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('enableDrag=false leaves the close button as the only dismissal path', () => {
+ const { onOpenChange } = renderSheet({
+ enableDrag: false,
+ dismissThresholdPx: 30,
+ });
+ const handle = screen.getByTestId('bottom-sheet-handle');
+ fireEvent.pointerDown(handle, { pointerId: 1, clientY: 100, button: 0 });
+ fireEvent.pointerMove(handle, { pointerId: 1, clientY: 400 });
+ fireEvent.pointerUp(handle, { pointerId: 1, clientY: 400 });
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+
+ // Close button still works.
+ screen.getByRole('button', { name: 'Close panel' }).click();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx
new file mode 100644
index 0000000..998c499
--- /dev/null
+++ b/src/components/ui/bottom-sheet.tsx
@@ -0,0 +1,295 @@
+/**
+ * Mobile bottom-sheet primitive with drag-to-dismiss (#314).
+ *
+ * Built on Radix Dialog so the panel inherits the focus trap, escape
+ * handling, and `role="dialog"` semantics the rest of the app already
+ * relies on — drag is a *progressive enhancement* on top of those
+ * affordances:
+ *
+ * - The close button (` `) always works, on every
+ * input device, and is the primary dismissal affordance for
+ * keyboard / screen-reader users.
+ * - Dragging the panel downward past `dismissThresholdPx` (default
+ * 96px) closes the sheet — matching iOS/Android platform
+ * conventions for mobile-first surfaces.
+ * - The drag handle (` `) is exposed as a separate
+ * component so callers can place it inside their sheet content
+ * rather than baking it into a fixed layout. It is a visual hint;
+ * the gesture is captured on the sheet's content surface.
+ * - Scrollable content inside the sheet is unaffected: drag is
+ * captured only when the gesture starts on the handle or when the
+ * inner scroller is already at scrollTop 0 *and* the gesture moves
+ * downward — preventing the "trying to scroll up" misfire.
+ *
+ * The component uses native pointer events so we don't pull a gesture
+ * library purely for one interaction; framer-motion is available in
+ * the bundle but not required here.
+ */
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+/** Pixels of downward travel required to commit a dismissal. */
+const DEFAULT_DISMISS_THRESHOLD_PX = 96;
+
+interface BottomSheetGestureContextValue {
+ registerHandle: (node: HTMLElement | null) => void;
+}
+
+const BottomSheetGestureContext =
+ React.createContext(null);
+
+export type BottomSheetProps = React.ComponentProps;
+
+export const BottomSheet = ({ ...props }: BottomSheetProps) => (
+
+);
+
+export const BottomSheetTrigger = DialogPrimitive.Trigger;
+export const BottomSheetClose = DialogPrimitive.Close;
+export const BottomSheetPortal = DialogPrimitive.Portal;
+
+export interface BottomSheetContentProps
+ extends React.ComponentProps {
+ /** Distance in px to drag downward before dismissal commits. */
+ dismissThresholdPx?: number;
+ /**
+ * When `false`, dragging is disabled entirely — the sheet falls
+ * back to close-button-only dismissal. Useful for tests and for
+ * surfaces where the inner content has its own gesture handlers.
+ */
+ enableDrag?: boolean;
+ /** Hide the default close button (callers can render their own). */
+ hideCloseButton?: boolean;
+}
+
+export const BottomSheetContent = React.forwardRef<
+ React.ElementRef,
+ BottomSheetContentProps
+>(
+ (
+ {
+ className,
+ children,
+ dismissThresholdPx = DEFAULT_DISMISS_THRESHOLD_PX,
+ enableDrag = true,
+ hideCloseButton = false,
+ onPointerDown,
+ ...props
+ },
+ ref
+ ) => {
+ const internalRef = React.useRef(null);
+ const setRefs = React.useCallback(
+ (node: HTMLDivElement | null) => {
+ internalRef.current = node;
+ if (typeof ref === "function") {
+ ref(node);
+ } else if (ref) {
+ (ref as React.MutableRefObject).current =
+ node;
+ }
+ },
+ [ref]
+ );
+
+ // Captured pointer state — null when no drag is active.
+ const dragStateRef = React.useRef<{
+ pointerId: number;
+ startY: number;
+ startedOnHandle: boolean;
+ } | null>(null);
+ const handleRef = React.useRef(null);
+
+ const registerHandle = React.useCallback(
+ (node: HTMLElement | null) => {
+ handleRef.current = node;
+ },
+ []
+ );
+
+ const resetTransform = React.useCallback(() => {
+ const node = internalRef.current;
+ if (!node) return;
+ node.style.transform = "";
+ node.style.transition = "transform 180ms ease-out";
+ // Drop the transition after it finishes so subsequent gestures
+ // start clean.
+ window.setTimeout(() => {
+ if (node) node.style.transition = "";
+ }, 200);
+ }, []);
+
+ const dismiss = React.useCallback(() => {
+ // Close the sheet by dispatching Escape — that lets Radix run
+ // its `onOpenChange(false)` and cleanup pipeline rather than us
+ // bypassing it.
+ internalRef.current?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
+ );
+ }, []);
+
+ const isGestureStartAllowed = React.useCallback(
+ (target: EventTarget | null): boolean => {
+ if (!enableDrag) return false;
+ const node = internalRef.current;
+ if (!node) return false;
+ const targetEl = target as HTMLElement | null;
+ // Always allow when the gesture starts on the registered
+ // handle — it's the "I'm grabbing the sheet" affordance.
+ if (handleRef.current && targetEl && handleRef.current.contains(targetEl)) {
+ return true;
+ }
+ // Otherwise only allow when no inner scroller is engaged:
+ // look at the nearest ancestor that is scrolling and bail
+ // out if its `scrollTop > 0`. This avoids stealing the
+ // downward-pull-to-scroll-up gesture.
+ let cursor: HTMLElement | null = targetEl;
+ while (cursor && cursor !== node) {
+ if (
+ cursor.scrollHeight > cursor.clientHeight &&
+ cursor.scrollTop > 0
+ ) {
+ return false;
+ }
+ cursor = cursor.parentElement;
+ }
+ return true;
+ },
+ [enableDrag]
+ );
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ onPointerDown?.(event);
+ if (event.defaultPrevented) return;
+ if (event.button !== undefined && event.button !== 0) return;
+ if (!isGestureStartAllowed(event.target)) return;
+
+ const node = internalRef.current;
+ if (!node) return;
+
+ const startedOnHandle = !!(
+ handleRef.current &&
+ event.target instanceof HTMLElement &&
+ handleRef.current.contains(event.target)
+ );
+ dragStateRef.current = {
+ pointerId: event.pointerId,
+ startY: event.clientY,
+ startedOnHandle,
+ };
+ try {
+ node.setPointerCapture(event.pointerId);
+ } catch {
+ /* JSDOM / older browsers: capture isn't required for the
+ pointermove path to still observe events. */
+ }
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ const drag = dragStateRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ const dy = event.clientY - drag.startY;
+ if (dy < 0) {
+ // Upward drag — clamp at 0; we don't expose pull-up.
+ const node = internalRef.current;
+ if (node) node.style.transform = "translate3d(0, 0, 0)";
+ return;
+ }
+ const node = internalRef.current;
+ if (node) node.style.transform = `translate3d(0, ${dy}px, 0)`;
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ const drag = dragStateRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) return;
+ const dy = event.clientY - drag.startY;
+ dragStateRef.current = null;
+ try {
+ internalRef.current?.releasePointerCapture(event.pointerId);
+ } catch {
+ /* see handlePointerDown */
+ }
+ if (dy >= dismissThresholdPx) {
+ dismiss();
+ } else {
+ resetTransform();
+ }
+ };
+
+ const gestureContext = React.useMemo(
+ () => ({ registerHandle }),
+ [registerHandle]
+ );
+
+ return (
+
+
+
+
+ {children}
+ {!hideCloseButton && (
+
+
+
+ )}
+
+
+
+ );
+ }
+);
+BottomSheetContent.displayName = "BottomSheetContent";
+
+/**
+ * Visual drag handle. Renders a small horizontal pill and registers
+ * itself with the parent `BottomSheetContent` so a gesture that starts
+ * inside the handle is always treated as "user grabbed the sheet"
+ * regardless of what's underneath.
+ */
+export const BottomSheetHandle: React.FC<{ className?: string }> = ({
+ className,
+}) => {
+ const ctx = React.useContext(BottomSheetGestureContext);
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ ctx?.registerHandle(ref.current);
+ return () => ctx?.registerHandle(null);
+ }, [ctx]);
+ return (
+
+ );
+};
+
+export const BottomSheetTitle = DialogPrimitive.Title;
+export const BottomSheetDescription = DialogPrimitive.Description;