| 1 | |
| 2 | |
| 3 | "use client"; |
| 4 | |
| 5 | import { useState, useEffect, useCallback } from "react"; |
| 6 | import { clsx } from "@/lib/cx"; |
| 7 | |
| 8 | export type CiteKind = "bill" | "vote" | "member" | "award" | "page"; |
| 9 | type CiteFormat = "apa" | "mla" | "chicago"; |
| 10 | |
| 11 | export interface CiteThisProps { |
| 12 | kind?: CiteKind; |
| 13 | title: string; |
| 14 | permalink: string; |
| 15 | sourceUrl: string; |
| 16 | year?: number; |
| 17 | } |
| 18 | |
| 19 | function longDate(d: Date): string { |
| 20 | return d.toLocaleDateString("en-US", { |
| 21 | year: "numeric", |
| 22 | month: "long", |
| 23 | day: "numeric", |
| 24 | }); |
| 25 | } |
| 26 | |
| 27 | function mlaDate(d: Date): string { |
| 28 | const day = d.getDate(); |
| 29 | const month = d.toLocaleDateString("en-US", { month: "long" }); |
| 30 | const year = d.getFullYear(); |
| 31 | return `${day} ${month} ${year}`; |
| 32 | } |
| 33 | |
| 34 | function bareUrl(url: string): string { |
| 35 | return url.replace(/^https?:\/\ |
| 36 | } |
| 37 | |
| 38 | function buildCitations( |
| 39 | title: string, |
| 40 | permalink: string, |
| 41 | sourceUrl: string, |
| 42 | year: number, |
| 43 | today: Date |
| 44 | ): Record<CiteFormat, string> { |
| 45 | const accessed = longDate(today); |
| 46 | const accessedMla = mlaDate(today); |
| 47 | |
| 48 | const apa = [ |
| 49 | `Docket. (${year}). ${title} [Data set]. U.S. Congress. Retrieved ${accessed}, from ${permalink}`, |
| 50 | `Official government source: ${sourceUrl}`, |
| 51 | ].join("\n"); |
| 52 | |
| 53 | const mla = [ |
| 54 | `"${title}." Docket, United States Congress, ${year}, ${bareUrl(permalink)}. Accessed ${accessedMla}.`, |
| 55 | `Official government source: ${sourceUrl}`, |
| 56 | ].join("\n"); |
| 57 | |
| 58 | const chicago = [ |
| 59 | `Docket. "${title}." United States Congress. ${year}. ${permalink} (accessed ${accessed}).`, |
| 60 | `Official government source: ${sourceUrl}`, |
| 61 | ].join("\n"); |
| 62 | |
| 63 | return { apa, mla, chicago }; |
| 64 | } |
| 65 | |
| 66 | const FORMAT_LABELS: Record<CiteFormat, string> = { |
| 67 | apa: "APA", |
| 68 | mla: "MLA", |
| 69 | chicago: "Chicago", |
| 70 | }; |
| 71 | const FORMATS: CiteFormat[] = ["apa", "mla", "chicago"]; |
| 72 | |
| 73 | export function CiteThis({ |
| 74 | title, |
| 75 | permalink, |
| 76 | sourceUrl, |
| 77 | year, |
| 78 | }: CiteThisProps) { |
| 79 | const [mounted, setMounted] = useState(false); |
| 80 | const [active, setActive] = useState<CiteFormat>("apa"); |
| 81 | const [copied, setCopied] = useState(false); |
| 82 | |
| 83 | useEffect(() => { |
| 84 | setMounted(true); |
| 85 | }, []); |
| 86 | |
| 87 | const today = mounted ? new Date() : null; |
| 88 | const pubYear = year ?? (today?.getFullYear() ?? new Date().getFullYear()); |
| 89 | const citations = today |
| 90 | ? buildCitations(title, permalink, sourceUrl, pubYear, today) |
| 91 | : null; |
| 92 | |
| 93 | const handleCopy = useCallback(async () => { |
| 94 | if (!citations) return; |
| 95 | try { |
| 96 | await navigator.clipboard.writeText(citations[active]); |
| 97 | setCopied(true); |
| 98 | setTimeout(() => setCopied(false), 2000); |
| 99 | } catch { |
| 100 | } |
| 101 | }, [citations, active]); |
| 102 | |
| 103 | const handleTabKeyDown = useCallback( |
| 104 | (e: React.KeyboardEvent<HTMLButtonElement>, idx: number) => { |
| 105 | let next: number | null = null; |
| 106 | if (e.key === "ArrowRight") next = (idx + 1) % FORMATS.length; |
| 107 | if (e.key === "ArrowLeft") next = (idx - 1 + FORMATS.length) % FORMATS.length; |
| 108 | if (next !== null) { |
| 109 | e.preventDefault(); |
| 110 | setActive(FORMATS[next]); |
| 111 | requestAnimationFrame(() => { |
| 112 | ( |
| 113 | document.querySelector( |
| 114 | `[data-cite-tab="${FORMATS[next!]}"]` |
| 115 | ) as HTMLButtonElement | null |
| 116 | )?.focus(); |
| 117 | }); |
| 118 | } |
| 119 | }, |
| 120 | [] |
| 121 | ); |
| 122 | |
| 123 | return ( |
| 124 | <section |
| 125 | aria-label="Cite this record" |
| 126 | className="rounded-[3px] border border-line bg-surface" |
| 127 | > |
| 128 | {} |
| 129 | <div className="flex items-center justify-between border-b border-line px-4 py-3 sm:px-5"> |
| 130 | <p className="label text-ash">Cite this</p> |
| 131 | {} |
| 132 | <div |
| 133 | role="tablist" |
| 134 | aria-label="Citation format" |
| 135 | className="flex gap-1" |
| 136 | > |
| 137 | {FORMATS.map((fmt, idx) => ( |
| 138 | <button |
| 139 | key={fmt} |
| 140 | role="tab" |
| 141 | data-cite-tab={fmt} |
| 142 | aria-selected={active === fmt} |
| 143 | onClick={() => setActive(fmt)} |
| 144 | onKeyDown={(e) => handleTabKeyDown(e, idx)} |
| 145 | tabIndex={active === fmt ? 0 : -1} |
| 146 | className={clsx( |
| 147 | "focus-ring rounded-[2px] px-3 py-1.5 font-mono text-[12px] transition-colors", |
| 148 | "min-h-[36px]", |
| 149 | active === fmt |
| 150 | ? "bg-graphite text-paper" |
| 151 | : "text-ash hover:bg-graphite hover:text-paper" |
| 152 | )} |
| 153 | > |
| 154 | {FORMAT_LABELS[fmt]} |
| 155 | </button> |
| 156 | ))} |
| 157 | </div> |
| 158 | </div> |
| 159 | |
| 160 | {} |
| 161 | <div className="px-4 py-4 sm:px-5"> |
| 162 | {mounted && citations ? ( |
| 163 | <pre |
| 164 | role="tabpanel" |
| 165 | aria-label={`${FORMAT_LABELS[active]} citation`} |
| 166 | className="whitespace-pre-wrap break-words font-mono text-[13px] leading-relaxed text-paper" |
| 167 | > |
| 168 | {citations[active]} |
| 169 | </pre> |
| 170 | ) : ( |
| 171 | <p className="font-mono text-[13px] text-ash-2"> |
| 172 | Loading citation… |
| 173 | </p> |
| 174 | )} |
| 175 | </div> |
| 176 | |
| 177 | {} |
| 178 | <div className="flex items-center justify-between border-t border-line px-4 py-3 sm:px-5"> |
| 179 | <p className="label text-ash-2"> |
| 180 | {} |
| 181 | APA · MLA · Chicago - copy and paste into your document or citation manager. |
| 182 | </p> |
| 183 | <button |
| 184 | type="button" |
| 185 | onClick={handleCopy} |
| 186 | disabled={!mounted} |
| 187 | aria-label={`Copy ${FORMAT_LABELS[active]} citation to clipboard`} |
| 188 | className={clsx( |
| 189 | "focus-ring label inline-flex min-h-[44px] items-center gap-2 rounded-[3px] border border-line px-3 py-2", |
| 190 | "transition-colors", |
| 191 | copied |
| 192 | ? "border-yea text-yea" |
| 193 | : "text-ash hover:bg-graphite hover:text-paper" |
| 194 | )} |
| 195 | style={copied ? { borderColor: "var(--dk-yea)", color: "var(--dk-yea)" } : undefined} |
| 196 | > |
| 197 | {copied ? ( |
| 198 | <> |
| 199 | <CopyIcon done /> |
| 200 | Copied |
| 201 | </> |
| 202 | ) : ( |
| 203 | <> |
| 204 | <CopyIcon /> |
| 205 | Copy {FORMAT_LABELS[active]} |
| 206 | </> |
| 207 | )} |
| 208 | </button> |
| 209 | </div> |
| 210 | </section> |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | function CopyIcon({ done = false }: { done?: boolean }) { |
| 215 | return done ? ( |
| 216 | <svg |
| 217 | aria-hidden="true" |
| 218 | width="14" |
| 219 | height="14" |
| 220 | viewBox="0 0 14 14" |
| 221 | fill="none" |
| 222 | stroke="currentColor" |
| 223 | strokeWidth="2" |
| 224 | strokeLinecap="round" |
| 225 | strokeLinejoin="round" |
| 226 | > |
| 227 | <polyline points="2 7 6 11 12 3" /> |
| 228 | </svg> |
| 229 | ) : ( |
| 230 | <svg |
| 231 | aria-hidden="true" |
| 232 | width="14" |
| 233 | height="14" |
| 234 | viewBox="0 0 14 14" |
| 235 | fill="none" |
| 236 | stroke="currentColor" |
| 237 | strokeWidth="1.5" |
| 238 | strokeLinecap="round" |
| 239 | strokeLinejoin="round" |
| 240 | > |
| 241 | <rect x="4" y="4" width="8" height="8" rx="1" /> |
| 242 | <path d="M2 9V2h7" /> |
| 243 | </svg> |
| 244 | ); |
| 245 | } |