From 798e7ef737ebb5e0967a8c58b21547ba35670bc5 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Thu, 28 May 2026 13:53:12 +0100 Subject: [PATCH] feat: profile header skeleton, accessible metric tooltips, bio line-clamp, pending-onboarding placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #291, #290, #282, #273. - #273: New CreatorProfileHeaderSkeleton (in CreatorSkeleton.tsx) renders avatar (size-24 / md:size-32), name (h-9 / md:h-12), and handle (h-6) block placeholders that align with CreatorProfileHeader's populated layout so no layout shift on resolve. role="status" + sr-only label announce the loading state; reduced-motion is respected via the shared skeleton block class. Wired into LandingPage with isLoading. - #290: New AccessibleInfoTrigger component — a focusable + {open && ( + + {explanation} + + )} + + ); +}; + +export default AccessibleInfoTrigger; diff --git a/src/components/common/CreatorBio.tsx b/src/components/common/CreatorBio.tsx index 7408e32..47e2924 100644 --- a/src/components/common/CreatorBio.tsx +++ b/src/components/common/CreatorBio.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { lineClampClassFor } from '@/utils/lineClamp.utils'; interface CreatorBioProps { /** Raw bio string from the creator profile. Anything falsy or whitespace-only is treated as missing. */ @@ -9,10 +10,29 @@ interface CreatorBioProps { variant?: 'card' | 'profile'; /** If true, returns null instead of a fallback when bio is missing. */ allowEmpty?: boolean; + /** + * When true and `bio` is empty, swap the generic "no bio" fallback for + * the pending-onboarding placeholder (#291) so visitors know the creator + * is still setting things up rather than seeing a blank section. + */ + isOnboardingPending?: boolean; + /** + * Clamp the rendered bio to at most this many lines on the card (#282). + * Only effective for the `card` variant — the `profile` variant always + * shows the full bio, so the truncation stays purely cosmetic and the + * full text remains accessible on the creator profile page. + * + * Pass `null` (or omit) to disable clamping; the default is `3` lines on + * the card, which matches the card grid's row height and keeps layouts + * uniform across varying bio lengths. Short bios are unaffected. + */ + maxLines?: number | null; 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; const variantClasses: Record<'card' | 'profile', { value: string; fallback: string }> = { card: { @@ -36,6 +56,8 @@ const CreatorBio: React.FC = ({ fallback = DEFAULT_FALLBACK, variant = 'card', allowEmpty = false, + isOnboardingPending = false, + maxLines, className, }) => { const trimmed = bio?.trim(); @@ -46,14 +68,42 @@ const CreatorBio: React.FC = ({ return null; } + const effectiveFallback = isOnboardingPending + ? 'This creator is still setting up their profile. Bio coming soon.' + : fallback; return ( -

- {fallback} +

+ {effectiveFallback}

); } - return

{trimmed}

; + // 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 + ? DEFAULT_CARD_MAX_LINES + : maxLines; + const clampClass = lineClampClassFor(variant, effectiveMaxLines); + + return ( +

+ {trimmed} +

+ ); }; export default CreatorBio; diff --git a/src/components/common/CreatorSkeleton.tsx b/src/components/common/CreatorSkeleton.tsx index 5527abc..a16fcf8 100644 --- a/src/components/common/CreatorSkeleton.tsx +++ b/src/components/common/CreatorSkeleton.tsx @@ -62,4 +62,61 @@ export const CreatorGridSkeleton: React.FC<{ ); }; +/** + * Loading skeleton for the creator profile header (#273) — avatar, name, and + * handle. Block dimensions match `CreatorProfileHeader`'s populated layout + * (size-24 / md:size-32 rounded-2xl avatar, h-9 md:h-12 name, h-6 handle) so + * there is no visible layout shift when real data lands. + * + * Respects `prefers-reduced-motion`: the shared `skeletonBlockClass` falls + * back to a static ring + a slightly brighter fill (`motion-reduce:`) so the + * shimmer animation is suppressed for users who opt out. `disableShimmer` is + * available for callers that already disable shimmer at a higher level. + * + * Includes `role="status"` and an `sr-only` label so screen-reader users + * know the header is loading rather than missing. + */ +export const CreatorProfileHeaderSkeleton: React.FC<{ + className?: string; + disableShimmer?: boolean; +}> = ({ + className, + disableShimmer = false, +}) => { + const blockClass = disableShimmer ? skeletonStaticBlockClass : skeletonBlockClass; + return ( +
+ Loading creator profile +
+ {/* + Match the live header's avatar: size-24 on mobile, + size-32 on md and up, with 4px border + rounded-2xl. + */} +
+
+ {/* Name placeholder — 3xl on mobile, 4xl on md+ */} +
+ {/* Handle placeholder — lg text */} +
+
+
+ + {/* Share button placeholder at the right end on md+ */} +
+
+ ); +}; + export default CreatorSkeleton; diff --git a/src/components/common/MiniStatChip.tsx b/src/components/common/MiniStatChip.tsx index 82a6b26..8a64623 100644 --- a/src/components/common/MiniStatChip.tsx +++ b/src/components/common/MiniStatChip.tsx @@ -1,14 +1,23 @@ import { cn } from '@/lib/utils'; +import AccessibleInfoTrigger from '@/components/common/AccessibleInfoTrigger'; interface MiniStatChipProps { label: string; value: string; + /** + * Optional explanation surfaced via an accessible tooltip trigger (#290). + * The trigger is a focusable button with `aria-describedby` pointing at + * the tooltip, so keyboard users can reach it and screen readers + * announce the explanation on focus. + */ + explanation?: string; className?: string; } const MiniStatChip: React.FC = ({ label, value, + explanation, className, }) => { return ( @@ -24,6 +33,12 @@ const MiniStatChip: React.FC = ({ {value} + {explanation && ( + + )}
); }; diff --git a/src/components/common/PendingOnboardingPlaceholder.tsx b/src/components/common/PendingOnboardingPlaceholder.tsx new file mode 100644 index 0000000..5ba7928 --- /dev/null +++ b/src/components/common/PendingOnboardingPlaceholder.tsx @@ -0,0 +1,73 @@ +import { Hourglass } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface PendingOnboardingPlaceholderProps { + /** + * The profile section the placeholder is filling in (e.g. "bio", "links", + * "stats"). Used to tailor the copy without forcing every caller to write + * their own; pass a custom `message` to fully override. + */ + section?: 'bio' | 'links' | 'stats' | 'overview'; + /** Override the default per-section copy. */ + message?: string; + /** `inline` is a compact one-liner; `card` is a centred boxed callout. */ + variant?: 'inline' | 'card'; + className?: string; +} + +const SECTION_COPY: Record, string> = { + bio: 'This creator is still setting up their profile. Bio coming soon.', + links: 'Social links will appear here once this creator finishes onboarding.', + stats: 'Creator stats will appear here once onboarding is complete.', + overview: 'This creator is still setting up their profile.', +}; + +/** + * Placeholder shown for creator profile sections that would otherwise render + * blank while onboarding is still in progress (#291). Centralising the copy + * keeps the tone consistent across every empty-state surface on the profile + * page — change it once here and every consumer picks it up. + */ +const PendingOnboardingPlaceholder: React.FC = ({ + section = 'overview', + message, + variant = 'inline', + className, +}) => { + const copy = message ?? SECTION_COPY[section]; + + if (variant === 'card') { + return ( +
+
+ ); + } + + return ( +

+

+ ); +}; + +export default PendingOnboardingPlaceholder; diff --git a/src/components/common/__tests__/AccessibleInfoTrigger.test.tsx b/src/components/common/__tests__/AccessibleInfoTrigger.test.tsx new file mode 100644 index 0000000..be9c372 --- /dev/null +++ b/src/components/common/__tests__/AccessibleInfoTrigger.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AccessibleInfoTrigger from '@/components/common/AccessibleInfoTrigger'; + +// --------------------------------------------------------------------------- +// #290 — Accessible tooltip trigger for creator metric explanations +// --------------------------------------------------------------------------- + +describe('AccessibleInfoTrigger', () => { + it('renders a focusable button with a screen-reader label', () => { + render( + + ); + const button = screen.getByRole('button', { + name: 'Explanation for Status', + }); + // `
@@ -688,14 +695,17 @@ function LandingPage() {
diff --git a/src/utils/lineClamp.utils.ts b/src/utils/lineClamp.utils.ts new file mode 100644 index 0000000..f7f3c1c --- /dev/null +++ b/src/utils/lineClamp.utils.ts @@ -0,0 +1,32 @@ +/** + * Tailwind `line-clamp` utility selector keyed by line count and bio variant + * (issue #282). Lives outside the React component file so importing it + * doesn't break the file's Fast-Refresh boundary. + * + * The mapping uses fixed class names rather than `line-clamp-${n}` so + * Tailwind's JIT keeps these utilities in the produced CSS bundle. + */ +export const lineClampClassFor = ( + variant: 'card' | 'profile', + maxLines: number | null | undefined +): string => { + if (variant !== 'card') return ''; + if (maxLines == null || maxLines <= 0) return ''; + switch (maxLines) { + case 1: + return 'line-clamp-1'; + case 2: + return 'line-clamp-2'; + case 3: + return 'line-clamp-3'; + case 4: + return 'line-clamp-4'; + case 5: + return 'line-clamp-5'; + case 6: + return 'line-clamp-6'; + default: + // Anything beyond 6 lines: cap at 6 to keep the card height bounded. + return 'line-clamp-6'; + } +};