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;