Skip to content

Commit 8c2d5b8

Browse files
authored
Merge pull request #311 from pre-cious-Igwealor/feat/profile-skeleton-bio-clamp-tooltip-pending
feat(a11y/ui): profile header skeleton, accessible metric tooltips, bio clamp, onboarding placeholders (#291/#290/#282/#273)
2 parents dd7f968 + 798e7ef commit 8c2d5b8

9 files changed

Lines changed: 501 additions & 11 deletions
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as React from 'react';
2+
import { Info } from 'lucide-react';
3+
import { cn } from '@/lib/utils';
4+
5+
interface AccessibleInfoTriggerProps {
6+
/** The explanatory text revealed on focus / hover / click. */
7+
explanation: string;
8+
/**
9+
* Accessible label for the trigger button. Defaults to a generic phrasing —
10+
* pass a metric-specific label (e.g. `Explanation for: Audience`) so screen
11+
* readers describe *what* the trigger explains.
12+
*/
13+
label?: string;
14+
className?: string;
15+
}
16+
17+
/**
18+
* Accessible tooltip trigger for creator metric explanations (#290).
19+
*
20+
* Why a new component instead of reusing `<Tooltip>`:
21+
* - The existing tooltip wraps content in a non-focusable `<div>` and toggles
22+
* visibility via `group-hover:` / `group-focus-within:`. That works only if
23+
* a focusable descendant exists inside the wrapper, so attaching it to a
24+
* plain label produces a hover-only tooltip.
25+
* - Metric explanations need to be reachable via keyboard. A real `<button>`
26+
* with `aria-describedby` pointing to a `role="tooltip"` element is the
27+
* standard accessible pattern and is what assistive tech expects.
28+
*
29+
* Behaviour:
30+
* - Focus / hover / click reveals the tooltip; Escape, blur, or mouse-leave
31+
* hides it. The Escape handler is bound to the trigger itself so it does
32+
* not leak to global keyboard handlers.
33+
* - The trigger keeps the same visual footprint whether the tooltip is open
34+
* or not (the popover is absolutely positioned).
35+
* - The tooltip content sits below the trigger by default; pass a custom
36+
* `className` if you need to flip positioning.
37+
*/
38+
export const AccessibleInfoTrigger: React.FC<AccessibleInfoTriggerProps> = ({
39+
explanation,
40+
label,
41+
className,
42+
}) => {
43+
const tooltipId = React.useId();
44+
const [open, setOpen] = React.useState(false);
45+
46+
const show = React.useCallback(() => setOpen(true), []);
47+
const hide = React.useCallback(() => setOpen(false), []);
48+
const toggle = React.useCallback(() => setOpen(prev => !prev), []);
49+
50+
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
51+
if (event.key === 'Escape' && open) {
52+
event.stopPropagation();
53+
hide();
54+
}
55+
};
56+
57+
return (
58+
<span className={cn('relative inline-flex', className)}>
59+
<button
60+
type="button"
61+
aria-label={label ?? 'Show explanation'}
62+
aria-describedby={open ? tooltipId : undefined}
63+
aria-expanded={open}
64+
onMouseEnter={show}
65+
onMouseLeave={hide}
66+
onFocus={show}
67+
onBlur={hide}
68+
onClick={toggle}
69+
onKeyDown={handleKeyDown}
70+
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"
71+
>
72+
<Info className="size-3" aria-hidden="true" />
73+
</button>
74+
{open && (
75+
<span
76+
id={tooltipId}
77+
role="tooltip"
78+
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"
79+
>
80+
{explanation}
81+
</span>
82+
)}
83+
</span>
84+
);
85+
};
86+
87+
export default AccessibleInfoTrigger;

‎src/components/common/CreatorBio.tsx‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cn } from '@/lib/utils';
2+
import { lineClampClassFor } from '@/utils/lineClamp.utils';
23

34
interface CreatorBioProps {
45
/** Raw bio string from the creator profile. Anything falsy or whitespace-only is treated as missing. */
@@ -9,10 +10,29 @@ interface CreatorBioProps {
910
variant?: 'card' | 'profile';
1011
/** If true, returns null instead of a fallback when bio is missing. */
1112
allowEmpty?: boolean;
13+
/**
14+
* When true and `bio` is empty, swap the generic "no bio" fallback for
15+
* the pending-onboarding placeholder (#291) so visitors know the creator
16+
* is still setting things up rather than seeing a blank section.
17+
*/
18+
isOnboardingPending?: boolean;
19+
/**
20+
* Clamp the rendered bio to at most this many lines on the card (#282).
21+
* Only effective for the `card` variant — the `profile` variant always
22+
* shows the full bio, so the truncation stays purely cosmetic and the
23+
* full text remains accessible on the creator profile page.
24+
*
25+
* Pass `null` (or omit) to disable clamping; the default is `3` lines on
26+
* the card, which matches the card grid's row height and keeps layouts
27+
* uniform across varying bio lengths. Short bios are unaffected.
28+
*/
29+
maxLines?: number | null;
1230
className?: string;
1331
}
1432

1533
const DEFAULT_FALLBACK = "This creator hasn't shared a bio yet.";
34+
/** Default maximum bio lines on the card. */
35+
const DEFAULT_CARD_MAX_LINES = 3;
1636

1737
const variantClasses: Record<'card' | 'profile', { value: string; fallback: string }> = {
1838
card: {
@@ -36,6 +56,8 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
3656
fallback = DEFAULT_FALLBACK,
3757
variant = 'card',
3858
allowEmpty = false,
59+
isOnboardingPending = false,
60+
maxLines,
3961
className,
4062
}) => {
4163
const trimmed = bio?.trim();
@@ -46,14 +68,42 @@ const CreatorBio: React.FC<CreatorBioProps> = ({
4668
return null;
4769
}
4870

71+
const effectiveFallback = isOnboardingPending
72+
? 'This creator is still setting up their profile. Bio coming soon.'
73+
: fallback;
4974
return (
50-
<p className={cn(styles.fallback, className)} aria-label="Bio not provided">
51-
{fallback}
75+
<p
76+
className={cn(styles.fallback, className)}
77+
aria-label={
78+
isOnboardingPending
79+
? 'Bio pending — onboarding in progress'
80+
: 'Bio not provided'
81+
}
82+
>
83+
{effectiveFallback}
5284
</p>
5385
);
5486
}
5587

56-
return <p className={cn(styles.value, className)}>{trimmed}</p>;
88+
// Card defaults to a 3-line clamp; explicit null disables it. Profile
89+
// variant ignores the prop so the full bio stays visible on the detail
90+
// page.
91+
const effectiveMaxLines =
92+
variant === 'card' && maxLines === undefined
93+
? DEFAULT_CARD_MAX_LINES
94+
: maxLines;
95+
const clampClass = lineClampClassFor(variant, effectiveMaxLines);
96+
97+
return (
98+
<p
99+
// Preserve the full bio in the accessible name so screen readers
100+
// can read the unclamped text — the visual truncation is cosmetic.
101+
title={clampClass ? trimmed : undefined}
102+
className={cn(styles.value, clampClass, className)}
103+
>
104+
{trimmed}
105+
</p>
106+
);
57107
};
58108

59109
export default CreatorBio;

‎src/components/common/CreatorSkeleton.tsx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,61 @@ export const CreatorGridSkeleton: React.FC<{
6262
);
6363
};
6464

65+
/**
66+
* Loading skeleton for the creator profile header (#273) — avatar, name, and
67+
* handle. Block dimensions match `CreatorProfileHeader`'s populated layout
68+
* (size-24 / md:size-32 rounded-2xl avatar, h-9 md:h-12 name, h-6 handle) so
69+
* there is no visible layout shift when real data lands.
70+
*
71+
* Respects `prefers-reduced-motion`: the shared `skeletonBlockClass` falls
72+
* back to a static ring + a slightly brighter fill (`motion-reduce:`) so the
73+
* shimmer animation is suppressed for users who opt out. `disableShimmer` is
74+
* available for callers that already disable shimmer at a higher level.
75+
*
76+
* Includes `role="status"` and an `sr-only` label so screen-reader users
77+
* know the header is loading rather than missing.
78+
*/
79+
export const CreatorProfileHeaderSkeleton: React.FC<{
80+
className?: string;
81+
disableShimmer?: boolean;
82+
}> = ({
83+
className,
84+
disableShimmer = false,
85+
}) => {
86+
const blockClass = disableShimmer ? skeletonStaticBlockClass : skeletonBlockClass;
87+
return (
88+
<div
89+
role="status"
90+
aria-label="Loading creator profile"
91+
className={cn(
92+
'flex flex-col gap-6 md:flex-row md:items-end md:justify-between',
93+
className
94+
)}
95+
>
96+
<span className="sr-only">Loading creator profile</span>
97+
<div className="flex flex-col gap-4 md:flex-row md:items-center md:gap-6">
98+
{/*
99+
Match the live header's avatar: size-24 on mobile,
100+
size-32 on md and up, with 4px border + rounded-2xl.
101+
*/}
102+
<div
103+
className={cn(
104+
'size-24 shrink-0 rounded-2xl border-4 border-white/10 md:size-32',
105+
blockClass
106+
)}
107+
/>
108+
<div className="min-w-0 flex-1 space-y-2">
109+
{/* Name placeholder — 3xl on mobile, 4xl on md+ */}
110+
<div className={cn('h-9 w-3/4 max-w-md md:h-12', blockClass)} />
111+
{/* Handle placeholder — lg text */}
112+
<div className={cn('h-6 w-1/2 max-w-xs', blockClass)} />
113+
</div>
114+
</div>
115+
116+
{/* Share button placeholder at the right end on md+ */}
117+
<div className={cn('hidden h-11 w-44 rounded-xl md:block', blockClass)} />
118+
</div>
119+
);
120+
};
121+
65122
export default CreatorSkeleton;

‎src/components/common/MiniStatChip.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import { cn } from '@/lib/utils';
2+
import AccessibleInfoTrigger from '@/components/common/AccessibleInfoTrigger';
23

34
interface MiniStatChipProps {
45
label: string;
56
value: string;
7+
/**
8+
* Optional explanation surfaced via an accessible tooltip trigger (#290).
9+
* The trigger is a focusable button with `aria-describedby` pointing at
10+
* the tooltip, so keyboard users can reach it and screen readers
11+
* announce the explanation on focus.
12+
*/
13+
explanation?: string;
614
className?: string;
715
}
816

917
const MiniStatChip: React.FC<MiniStatChipProps> = ({
1018
label,
1119
value,
20+
explanation,
1221
className,
1322
}) => {
1423
return (
@@ -24,6 +33,12 @@ const MiniStatChip: React.FC<MiniStatChipProps> = ({
2433
<span className="truncate font-jakarta text-xs font-semibold text-white">
2534
{value}
2635
</span>
36+
{explanation && (
37+
<AccessibleInfoTrigger
38+
explanation={explanation}
39+
label={`Explanation for ${label}`}
40+
/>
41+
)}
2742
</div>
2843
);
2944
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Hourglass } from 'lucide-react';
2+
import { cn } from '@/lib/utils';
3+
4+
interface PendingOnboardingPlaceholderProps {
5+
/**
6+
* The profile section the placeholder is filling in (e.g. "bio", "links",
7+
* "stats"). Used to tailor the copy without forcing every caller to write
8+
* their own; pass a custom `message` to fully override.
9+
*/
10+
section?: 'bio' | 'links' | 'stats' | 'overview';
11+
/** Override the default per-section copy. */
12+
message?: string;
13+
/** `inline` is a compact one-liner; `card` is a centred boxed callout. */
14+
variant?: 'inline' | 'card';
15+
className?: string;
16+
}
17+
18+
const SECTION_COPY: Record<NonNullable<PendingOnboardingPlaceholderProps['section']>, string> = {
19+
bio: 'This creator is still setting up their profile. Bio coming soon.',
20+
links: 'Social links will appear here once this creator finishes onboarding.',
21+
stats: 'Creator stats will appear here once onboarding is complete.',
22+
overview: 'This creator is still setting up their profile.',
23+
};
24+
25+
/**
26+
* Placeholder shown for creator profile sections that would otherwise render
27+
* blank while onboarding is still in progress (#291). Centralising the copy
28+
* keeps the tone consistent across every empty-state surface on the profile
29+
* page — change it once here and every consumer picks it up.
30+
*/
31+
const PendingOnboardingPlaceholder: React.FC<PendingOnboardingPlaceholderProps> = ({
32+
section = 'overview',
33+
message,
34+
variant = 'inline',
35+
className,
36+
}) => {
37+
const copy = message ?? SECTION_COPY[section];
38+
39+
if (variant === 'card') {
40+
return (
41+
<div
42+
role="status"
43+
aria-live="polite"
44+
className={cn(
45+
'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',
46+
className
47+
)}
48+
>
49+
<Hourglass
50+
className="size-5 text-amber-400/80"
51+
aria-hidden="true"
52+
/>
53+
<p className="font-jakarta text-sm text-white/70">{copy}</p>
54+
</div>
55+
);
56+
}
57+
58+
return (
59+
<p
60+
role="status"
61+
aria-live="polite"
62+
className={cn(
63+
'inline-flex items-center gap-1.5 font-jakarta text-xs italic text-white/55',
64+
className
65+
)}
66+
>
67+
<Hourglass className="size-3 text-amber-400/70" aria-hidden="true" />
68+
{copy}
69+
</p>
70+
);
71+
};
72+
73+
export default PendingOnboardingPlaceholder;

0 commit comments

Comments
 (0)