Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1'use client';
2
3import { useState, useCallback, useId } from 'react';
4import { CheckCircle, Lightning, X } from '@phosphor-icons/react';
5import { Badge } from '@/components/ui/badge';
6import type { ProfileSkill } from '@/lib/types';
7
8export interface SkillChipProps {
9 skill: ProfileSkill;
10 showCategory?: boolean;
11 editable?: boolean;
12 onEdit?: () => void;
13 onDelete?: () => void;
14}
15
16type VisualState = 'self-declared' | 'endorsed' | 'activity-backed';
17
18function getVisualState(skill: ProfileSkill): VisualState {
19 if (skill.activityBacked) return 'activity-backed';
20 if (skill.endorsed) return 'endorsed';
21 return 'self-declared';
22}
23
24const TOOLTIP_TEXT: Record<Exclude<VisualState, 'self-declared'>, string> = {
25 endorsed: "Confirmed by people who've worked with you",
26 'activity-backed': 'Backed by verified activity',
27};
28
29export function SkillChip({ skill, showCategory, editable, onEdit, onDelete }: SkillChipProps) {
30 const [tooltipVisible, setTooltipVisible] = useState(false);
31 const tooltipId = useId();
32 const state = getVisualState(skill);
33 const hasTooltip = state !== 'self-declared';
34
35 const showTooltip = useCallback(() => {
36 if (hasTooltip) setTooltipVisible(true);
37 }, [hasTooltip]);
38
39 const hideTooltip = useCallback(() => {
40 setTooltipVisible(false);
41 }, []);
42
43 const isClickable = !!editable;
44
45 const handleClick = isClickable ? onEdit : undefined;
46 const handleKeyDown = isClickable
47 ? (e: React.KeyboardEvent) => {
48 if (e.key === 'Enter' || e.key === ' ') {
49 e.preventDefault();
50 onEdit?.();
51 }
52 }
53 : undefined;
54
55 return (
56 /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- mouse hover for tooltip; keyboard access is on the focusable Badge */
57 <span
58 className="relative inline-flex"
59 data-skill-chip=""
60 onMouseEnter={showTooltip}
61 onMouseLeave={hideTooltip}
62 >
63 <Badge
64 variant="secondary"
65 className={
66 isClickable ? 'cursor-pointer transition-colors hover:bg-secondary/80' : undefined
67 }
68 onClick={handleClick}
69 role={isClickable ? 'button' : undefined}
70 tabIndex={hasTooltip || isClickable ? 0 : undefined}
71 onKeyDown={handleKeyDown}
72 onFocus={showTooltip}
73 onBlur={hideTooltip}
74 aria-describedby={hasTooltip ? tooltipId : undefined}
75 >
76 {showCategory && skill.category && (
77 <span className="mr-1 text-muted-foreground">{skill.category}:</span>
78 )}
79 {skill.name}
80 {state === 'endorsed' && (
81 <CheckCircle
82 className="ml-1 h-3 w-3 text-muted-foreground"
83 weight="fill"
84 aria-hidden="true"
85 data-testid="endorsed-icon"
86 />
87 )}
88 {state === 'activity-backed' && (
89 <Lightning
90 className="ml-1 h-3 w-3 text-muted-foreground"
91 weight="fill"
92 aria-hidden="true"
93 data-testid="activity-backed-icon"
94 />
95 )}
96 {editable && onDelete && (
97 <button
98 type="button"
99 className="ml-1.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
100 onClick={(e) => {
101 e.stopPropagation();
102 onDelete();
103 }}
104 aria-label={`Remove ${skill.name}`}
105 >
106 <X className="h-3 w-3" weight="bold" aria-hidden="true" />
107 </button>
108 )}
109 </Badge>
110 {hasTooltip && tooltipVisible && (
111 <span
112 id={tooltipId}
113 role="tooltip"
114 aria-label={TOOLTIP_TEXT[state as Exclude<VisualState, 'self-declared'>]}
115 className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md"
116 >
117 {TOOLTIP_TEXT[state as Exclude<VisualState, 'self-declared'>]}
118 </span>
119 )}
120 </span>
121 );
122}