● Zion Boggan
repos/Docket/components/cite-this.tsx
zionboggan.com ↗
245 lines · tsx
History for this file →
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
}