From 3182219217b5013dd6034b7d1aa70c50a8f72ce2 Mon Sep 17 00:00:00 2001 From: Loveth Onyedikachukwu Date: Thu, 28 May 2026 15:58:11 +0100 Subject: [PATCH] feat: profile bio auto-collapse + expand toggle, mobile bottom sheet with drag-to-dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #315, #314. - #315 CreatorBio: gains collapsible + collapsedMaxLines + collapseThresholdChars props. When enabled on the profile variant and the bio is long enough (default >200 chars), the paragraph renders clamped with a focusable Show more / Show less toggle that carries aria-expanded + aria-controls so screen readers know the bio's collapsed state. Short bios are unaffected (no toggle, no clamp). The card variant ignores collapsible since it already clamps via maxLines. Wired into CreatorProfileHeader where the profile bio actually renders. - #314 BottomSheet: new mobile-first primitive built on Radix Dialog for the focus trap / role / Escape handling. Adds drag-to-dismiss via native pointer events: * The visual handle (BottomSheetHandle) registers itself with the sheet's content surface so a gesture that starts on the handle is always treated as 'grabbing the sheet'. * Otherwise the gesture is captured only when no inner scroller is engaged (walks up from the target, bails if any ancestor has scrollTop>0) — so a downward swipe on scrollable content scrolls instead of dismissing. * Dragging past dismissThresholdPx (default 96) dismisses by dispatching Escape so Radix's onOpenChange(false) pipeline runs. * Short / upward drags snap back via a brief transform reset. * The default close button always works as an alternative; pass enableDrag=false to make it the only path. Tests (12 new, all passing): - src/components/common/__tests__/CreatorBio.test.tsx (6 new cases): no toggle for short bio, no engagement on card variant, clamps + Show more wiring, toggles to Show less + removes clamp, custom collapseThresholdChars, custom collapsedMaxLines. - src/components/ui/bottom-sheet.test.tsx (6 cases): render + handle + close button, close-button dismissal, drag past threshold dismisses, short drag does not, upward drag clamps + never dismisses, enableDrag=false leaves close button as only path. Repo verification: - pnpm test 129/130 (1 pre-existing CreatorInitialsAvatar failure, same one disclosed in #310 / #311 / #324). - pnpm lint clean - pnpm build clean Co-Authored-By: Claude Opus 4.7 --- src/components/common/CreatorBio.tsx | 81 ++++- .../common/CreatorProfileHeader.tsx | 9 +- .../common/__tests__/CreatorBio.test.tsx | 69 +++- src/components/ui/bottom-sheet.test.tsx | 94 ++++++ src/components/ui/bottom-sheet.tsx | 295 ++++++++++++++++++ 5 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 src/components/ui/bottom-sheet.test.tsx create mode 100644 src/components/ui/bottom-sheet.tsx 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} + +
+ ); }; 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 + + + + ); + 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;