Sifa professional network frontend (Next.js, React, TailwindCSS) sifa.id/
at main 122 lines 4.1 kB view raw
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}