Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions src/components/common/CreatorBio.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId, useState } from 'react';
import { cn } from '@/lib/utils';
import { lineClampClassFor } from '@/utils/lineClamp.utils';

Expand Down Expand Up @@ -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: {
Expand All @@ -58,10 +88,15 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
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) {
Expand All @@ -85,17 +120,32 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
);
}

// `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 = (
<p
id={shouldOfferCollapse ? bioId : undefined}
// Preserve the full bio in the accessible name so screen readers
// can read the unclamped text — the visual truncation is cosmetic.
title={clampClass ? trimmed : undefined}
Expand All @@ -104,6 +154,25 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
{trimmed}
</p>
);

if (!shouldOfferCollapse) {
return bioParagraph;
}

return (
<div className="space-y-1">
{bioParagraph}
<button
type="button"
aria-expanded={expanded}
aria-controls={bioId}
onClick={() => 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'}
</button>
</div>
);
};

export default CreatorBio;
9 changes: 8 additions & 1 deletion src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,14 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
>
{displayHandle || `@${handle}`}
</p>
<CreatorBio bio={bio} variant="profile" className="mt-2 max-w-md" />
{/* #315: profile bio auto-collapses with a Show more / less
toggle once long enough. Short bios render unchanged. */}
<CreatorBio
bio={bio}
variant="profile"
collapsible
className="mt-2 max-w-md"
/>
</div>
</div>

Expand Down
69 changes: 68 additions & 1 deletion src/components/common/__tests__/CreatorBio.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(<CreatorBio bio="Short bio." variant="profile" collapsible />);
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(<CreatorBio bio={LONG} variant="card" collapsible />);
expect(screen.queryByRole('button')).toBeNull();
});

it('clamps a long profile bio and renders a Show more button', () => {
render(<CreatorBio bio={LONG} variant="profile" collapsible />);
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(<CreatorBio bio={LONG} variant="profile" collapsible />);
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(
<CreatorBio
bio="Medium length bio with about forty chars."
variant="profile"
collapsible
collapseThresholdChars={10}
/>
);
expect(screen.getByRole('button', { name: 'Show more' })).toBeInTheDocument();
});

it('clamps to collapsedMaxLines when supplied', () => {
render(
<CreatorBio
bio={LONG}
variant="profile"
collapsible
collapsedMaxLines={2}
/>
);
expect(screen.getByText(LONG).className).toMatch(/\bline-clamp-2\b/);
});
});
94 changes: 94 additions & 0 deletions src/components/ui/bottom-sheet.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<BottomSheet open onOpenChange={onOpenChange}>
<BottomSheetContent
dismissThresholdPx={props?.dismissThresholdPx}
enableDrag={props?.enableDrag}
>
<BottomSheetHandle />
<BottomSheetTitle>Mobile actions</BottomSheetTitle>
<button type="button">Inner action</button>
</BottomSheetContent>
</BottomSheet>
);
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);
});
});
Loading
Loading