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 = (