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
87 changes: 87 additions & 0 deletions src/components/common/AccessibleInfoTrigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as React from 'react';
import { Info } from 'lucide-react';
import { cn } from '@/lib/utils';

interface AccessibleInfoTriggerProps {
/** The explanatory text revealed on focus / hover / click. */
explanation: string;
/**
* Accessible label for the trigger button. Defaults to a generic phrasing —
* pass a metric-specific label (e.g. `Explanation for: Audience`) so screen
* readers describe *what* the trigger explains.
*/
label?: string;
className?: string;
}

/**
* Accessible tooltip trigger for creator metric explanations (#290).
*
* Why a new component instead of reusing `<Tooltip>`:
* - The existing tooltip wraps content in a non-focusable `<div>` and toggles
* visibility via `group-hover:` / `group-focus-within:`. That works only if
* a focusable descendant exists inside the wrapper, so attaching it to a
* plain label produces a hover-only tooltip.
* - Metric explanations need to be reachable via keyboard. A real `<button>`
* with `aria-describedby` pointing to a `role="tooltip"` element is the
* standard accessible pattern and is what assistive tech expects.
*
* Behaviour:
* - Focus / hover / click reveals the tooltip; Escape, blur, or mouse-leave
* hides it. The Escape handler is bound to the trigger itself so it does
* not leak to global keyboard handlers.
* - The trigger keeps the same visual footprint whether the tooltip is open
* or not (the popover is absolutely positioned).
* - The tooltip content sits below the trigger by default; pass a custom
* `className` if you need to flip positioning.
*/
export const AccessibleInfoTrigger: React.FC<AccessibleInfoTriggerProps> = ({
explanation,
label,
className,
}) => {
const tooltipId = React.useId();
const [open, setOpen] = React.useState(false);

const show = React.useCallback(() => setOpen(true), []);
const hide = React.useCallback(() => setOpen(false), []);
const toggle = React.useCallback(() => setOpen(prev => !prev), []);

const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Escape' && open) {
event.stopPropagation();
hide();
}
};

return (
<span className={cn('relative inline-flex', className)}>
<button
type="button"
aria-label={label ?? 'Show explanation'}
aria-describedby={open ? tooltipId : undefined}
aria-expanded={open}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
onClick={toggle}
onKeyDown={handleKeyDown}
className="inline-flex size-4 items-center justify-center rounded-full text-white/55 transition-colors hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-950"
>
<Info className="size-3" aria-hidden="true" />
</button>
{open && (
<span
id={tooltipId}
role="tooltip"
className="absolute left-1/2 top-full z-50 mt-2 w-max max-w-xs -translate-x-1/2 rounded-md border border-white/10 bg-slate-950/95 px-2.5 py-1.5 text-[0.7rem] font-medium leading-snug text-white/85 shadow-lg backdrop-blur"
>
{explanation}
</span>
)}
</span>
);
};

export default AccessibleInfoTrigger;
56 changes: 53 additions & 3 deletions src/components/common/CreatorBio.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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: {
Expand All @@ -36,6 +56,8 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
fallback = DEFAULT_FALLBACK,
variant = 'card',
allowEmpty = false,
isOnboardingPending = false,
maxLines,
className,
}) => {
const trimmed = bio?.trim();
Expand All @@ -46,14 +68,42 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
return null;
}

const effectiveFallback = isOnboardingPending
? 'This creator is still setting up their profile. Bio coming soon.'
: fallback;
return (
<p className={cn(styles.fallback, className)} aria-label="Bio not provided">
{fallback}
<p
className={cn(styles.fallback, className)}
aria-label={
isOnboardingPending
? 'Bio pending — onboarding in progress'
: 'Bio not provided'
}
>
{effectiveFallback}
</p>
);
}

return <p className={cn(styles.value, className)}>{trimmed}</p>;
// 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 (
<p
// 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}
className={cn(styles.value, clampClass, className)}
>
{trimmed}
</p>
);
};

export default CreatorBio;
57 changes: 57 additions & 0 deletions src/components/common/CreatorSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
role="status"
aria-label="Loading creator profile"
className={cn(
'flex flex-col gap-6 md:flex-row md:items-end md:justify-between',
className
)}
>
<span className="sr-only">Loading creator profile</span>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:gap-6">
{/*
Match the live header's avatar: size-24 on mobile,
size-32 on md and up, with 4px border + rounded-2xl.
*/}
<div
className={cn(
'size-24 shrink-0 rounded-2xl border-4 border-white/10 md:size-32',
blockClass
)}
/>
<div className="min-w-0 flex-1 space-y-2">
{/* Name placeholder — 3xl on mobile, 4xl on md+ */}
<div className={cn('h-9 w-3/4 max-w-md md:h-12', blockClass)} />
{/* Handle placeholder — lg text */}
<div className={cn('h-6 w-1/2 max-w-xs', blockClass)} />
</div>
</div>

{/* Share button placeholder at the right end on md+ */}
<div className={cn('hidden h-11 w-44 rounded-xl md:block', blockClass)} />
</div>
);
};

export default CreatorSkeleton;
15 changes: 15 additions & 0 deletions src/components/common/MiniStatChip.tsx
Original file line number Diff line number Diff line change
@@ -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<MiniStatChipProps> = ({
label,
value,
explanation,
className,
}) => {
return (
Expand All @@ -24,6 +33,12 @@ const MiniStatChip: React.FC<MiniStatChipProps> = ({
<span className="truncate font-jakarta text-xs font-semibold text-white">
{value}
</span>
{explanation && (
<AccessibleInfoTrigger
explanation={explanation}
label={`Explanation for ${label}`}
/>
)}
</div>
);
};
Expand Down
73 changes: 73 additions & 0 deletions src/components/common/PendingOnboardingPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<PendingOnboardingPlaceholderProps['section']>, 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<PendingOnboardingPlaceholderProps> = ({
section = 'overview',
message,
variant = 'inline',
className,
}) => {
const copy = message ?? SECTION_COPY[section];

if (variant === 'card') {
return (
<div
role="status"
aria-live="polite"
className={cn(
'flex flex-col items-center gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/[0.06] px-4 py-5 text-center',
className
)}
>
<Hourglass
className="size-5 text-amber-400/80"
aria-hidden="true"
/>
<p className="font-jakarta text-sm text-white/70">{copy}</p>
</div>
);
}

return (
<p
role="status"
aria-live="polite"
className={cn(
'inline-flex items-center gap-1.5 font-jakarta text-xs italic text-white/55',
className
)}
>
<Hourglass className="size-3 text-amber-400/70" aria-hidden="true" />
{copy}
</p>
);
};

export default PendingOnboardingPlaceholder;
Loading
Loading