emdash-header-footer-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import { Badge, Banner, Empty, Link, Loader, Table } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+ import type { ChangeLogEntry } from "../core/types.js";
4
+ import { api } from "./api.js";
5
+ import { ACTION_META, formatTimestamp, relativeTime, userLabel } from "./format.js";
6
+
7
+ export function ChangeLog({ snippetIds, onOpen }: { snippetIds: ReadonlySet<string>; onOpen: (id: string) => void }) {
8
+ const [entries, setEntries] = React.useState<ChangeLogEntry[] | null>(null);
9
+ const [error, setError] = React.useState<string | null>(null);
10
+ const [now] = React.useState(() => Date.now());
11
+
12
+ React.useEffect(() => {
13
+ api.changelog().then(
14
+ (r) => setEntries(r.entries),
15
+ (e: unknown) => setError(e instanceof Error ? e.message : String(e)),
16
+ );
17
+ }, []);
18
+
19
+ if (error) {
20
+ return (
21
+ <div className="p-4">
22
+ <Banner variant="error" title="Could not load the change log" description={error} />
23
+ </div>
24
+ );
25
+ }
26
+ if (!entries) {
27
+ return (
28
+ <div className="flex justify-center py-10">
29
+ <Loader />
30
+ </div>
31
+ );
32
+ }
33
+ if (entries.length === 0) {
34
+ return <Empty title="No changes yet" description="Creating, editing or deleting snippets is recorded here." />;
35
+ }
36
+ return (
37
+ <div className="overflow-x-auto">
38
+ <Table>
39
+ <Table.Header>
40
+ <Table.Row>
41
+ <Table.Head>When</Table.Head>
42
+ <Table.Head>Who</Table.Head>
43
+ <Table.Head>Action</Table.Head>
44
+ <Table.Head>Snippet</Table.Head>
45
+ </Table.Row>
46
+ </Table.Header>
47
+ <Table.Body>
48
+ {entries.map((e) => {
49
+ const meta = ACTION_META[e.action];
50
+ const canOpen = e.snippetId !== null && snippetIds.has(e.snippetId);
51
+ return (
52
+ <Table.Row key={e.id} data-testid="hfc-changelog-row">
53
+ <Table.Cell className="whitespace-nowrap text-sm">
54
+ <time dateTime={e.at} title={formatTimestamp(e.at)}>
55
+ {relativeTime(e.at, now)}
56
+ </time>
57
+ </Table.Cell>
58
+ <Table.Cell className="text-sm">{userLabel(e.user)}</Table.Cell>
59
+ <Table.Cell>
60
+ <Badge variant={meta?.variant ?? "secondary"}>{meta?.label ?? e.action}</Badge>
61
+ </Table.Cell>
62
+ <Table.Cell className="text-sm">
63
+ {e.snippetName === null ? (
64
+ <span className="text-kumo-subtle">—</span>
65
+ ) : canOpen ? (
66
+ <Link
67
+ variant="plain"
68
+ render={<button type="button" className="cursor-pointer text-start" />}
69
+ onClick={() => onOpen(e.snippetId!)}
70
+ >
71
+ {e.snippetName}
72
+ </Link>
73
+ ) : (
74
+ <span>{e.snippetName}</span>
75
+ )}
76
+ </Table.Cell>
77
+ </Table.Row>
78
+ );
79
+ })}
80
+ </Table.Body>
81
+ </Table>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,154 @@
1
+ import { cn } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+ import { type ChipKind, addChips, validateChip } from "./chips.js";
4
+ import { XIcon } from "./icons.js";
5
+
6
+ /**
7
+ * Tag-style list input: type and press Enter (or a comma) to add, Backspace on an empty input
8
+ * removes the last chip, × removes one. Invalid values stay in the input and the reason is
9
+ * reported through `onErrorChange` (the form shows it under the field).
10
+ */
11
+ export function ChipInput({
12
+ id,
13
+ kind,
14
+ value,
15
+ onChange,
16
+ onErrorChange,
17
+ onPendingChange,
18
+ invalid,
19
+ describedBy,
20
+ label,
21
+ placeholder,
22
+ }: {
23
+ id: string;
24
+ kind: ChipKind;
25
+ value: string[];
26
+ onChange: (value: string[]) => void;
27
+ onErrorChange: (error: string | null) => void;
28
+ /** Reports whether the input holds text not yet added as a chip (counts as unsaved). */
29
+ onPendingChange?: (pending: boolean) => void;
30
+ invalid?: boolean;
31
+ describedBy?: string;
32
+ label: string;
33
+ placeholder?: string;
34
+ }) {
35
+ const inputRef = React.useRef<HTMLInputElement>(null);
36
+ const [text, setText] = React.useState("");
37
+ // After a rejected add, re-validate as the user edits so the message clears once fixed.
38
+ const [liveCheck, setLiveCheck] = React.useState(false);
39
+ // IME composition: Enter/comma confirm the composed text, they must not add a chip.
40
+ const composing = React.useRef(false);
41
+
42
+ const pending = text.trim() !== "";
43
+ const onPendingRef = React.useRef(onPendingChange);
44
+ onPendingRef.current = onPendingChange;
45
+ React.useEffect(() => {
46
+ onPendingRef.current?.(pending);
47
+ }, [pending]);
48
+
49
+ function commit(raw: string) {
50
+ const r = addChips(value, raw, kind);
51
+ if (r.chips.length !== value.length) onChange(r.chips);
52
+ setText(r.rest);
53
+ setLiveCheck(r.error !== null);
54
+ onErrorChange(r.error);
55
+ }
56
+
57
+ function onTextChange(next: string) {
58
+ if (!composing.current && /[,\n]/.test(next)) {
59
+ commit(next);
60
+ return;
61
+ }
62
+ setText(next);
63
+ if (liveCheck) {
64
+ const t = next.trim();
65
+ const err = t === "" ? null : validateChip(t, kind);
66
+ onErrorChange(err);
67
+ if (err === null) setLiveCheck(false);
68
+ }
69
+ }
70
+
71
+ function remove(index: number) {
72
+ onChange(value.filter((_, i) => i !== index));
73
+ inputRef.current?.focus();
74
+ }
75
+
76
+ function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
77
+ if (e.nativeEvent.isComposing || composing.current) return;
78
+ if (e.key === "Enter") {
79
+ e.preventDefault();
80
+ commit(text);
81
+ } else if (e.key === "Backspace" && text === "" && value.length > 0) {
82
+ e.preventDefault();
83
+ remove(value.length - 1);
84
+ }
85
+ }
86
+
87
+ return (
88
+ // biome-ignore lint/a11y/useKeyWithClickEvents: clicking the padding focuses the real input
89
+ <div
90
+ className={cn(
91
+ "flex min-h-9 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg bg-kumo-base px-2 py-1.5 ring ring-kumo-line focus-within:ring-kumo-focus/50",
92
+ invalid && "!ring-kumo-danger",
93
+ )}
94
+ onClick={(e) => {
95
+ if (e.target === e.currentTarget) inputRef.current?.focus();
96
+ }}
97
+ >
98
+ {value.map((chip, i) => (
99
+ <span
100
+ key={chip}
101
+ data-testid={`${id}-chip`}
102
+ className="inline-flex max-w-full items-center gap-1 rounded-md bg-kumo-tint py-0.5 ps-2 pr-1 font-mono text-sm text-kumo-default"
103
+ >
104
+ <span className="truncate">{chip}</span>
105
+ <button
106
+ type="button"
107
+ className="inline-flex cursor-pointer items-center rounded p-0.5 text-kumo-subtle hover:bg-kumo-fill hover:text-kumo-default"
108
+ aria-label={`Remove ${chip}`}
109
+ onClick={() => remove(i)}
110
+ >
111
+ <XIcon size={12} />
112
+ </button>
113
+ </span>
114
+ ))}
115
+ <input
116
+ ref={inputRef}
117
+ id={id}
118
+ data-testid={id}
119
+ aria-label={label}
120
+ aria-invalid={invalid || undefined}
121
+ aria-describedby={describedBy}
122
+ className="min-w-32 flex-1 bg-transparent px-1 py-0.5 text-sm text-kumo-default outline-none"
123
+ value={text}
124
+ placeholder={value.length === 0 ? placeholder : undefined}
125
+ onChange={(e) => onTextChange(e.target.value)}
126
+ onKeyDown={onKeyDown}
127
+ onCompositionStart={() => {
128
+ composing.current = true;
129
+ }}
130
+ onCompositionEnd={(e) => {
131
+ composing.current = false;
132
+ onTextChange(e.currentTarget.value);
133
+ }}
134
+ onPaste={(e) => {
135
+ // A single-line <input> would strip pasted newlines ("/a\n/b" -> "/a/b"), so split
136
+ // multi-value pastes here and add each value.
137
+ const pasted = e.clipboardData.getData("text");
138
+ if (!/[,\r\n]/.test(pasted)) return;
139
+ e.preventDefault();
140
+ const el = e.currentTarget;
141
+ const start = el.selectionStart ?? text.length;
142
+ const end = el.selectionEnd ?? text.length;
143
+ commit(`${text.slice(0, start)}${pasted.replace(/\r/g, "")}${text.slice(end)}`);
144
+ }}
145
+ onBlur={() => {
146
+ if (text.trim() !== "") commit(text);
147
+ }}
148
+ spellCheck={false}
149
+ autoCapitalize="off"
150
+ autoComplete="off"
151
+ />
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,135 @@
1
+ import { cn } from "@cloudflare/kumo";
2
+ import * as React from "react";
3
+ import { indentText } from "./indent.js";
4
+
5
+ /**
6
+ * Code textarea with a line-number gutter and Tab/Shift+Tab indenting. No editor dependency:
7
+ * the gutter is a <pre> kept in step with the textarea's scroll position. Press Esc, then Tab,
8
+ * to move focus out of the editor with the keyboard.
9
+ */
10
+ export function CodeEditor({
11
+ id,
12
+ value,
13
+ onChange,
14
+ invalid,
15
+ describedBy,
16
+ label,
17
+ }: {
18
+ id: string;
19
+ value: string;
20
+ onChange: (value: string) => void;
21
+ invalid?: boolean;
22
+ describedBy?: string;
23
+ label: string;
24
+ }) {
25
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
26
+ const gutterRef = React.useRef<HTMLPreElement>(null);
27
+ const tabEscape = React.useRef(false);
28
+ const pendingSelection = React.useRef<[number, number] | null>(null);
29
+
30
+ const lineCount = React.useMemo(() => {
31
+ let n = 1;
32
+ for (let i = value.indexOf("\n"); i !== -1; i = value.indexOf("\n", i + 1)) n++;
33
+ return n;
34
+ }, [value]);
35
+ const numbers = React.useMemo(
36
+ () => Array.from({ length: lineCount }, (_, i) => String(i + 1)).join("\n"),
37
+ [lineCount],
38
+ );
39
+
40
+ const syncScroll = React.useCallback(() => {
41
+ if (gutterRef.current && textareaRef.current) gutterRef.current.scrollTop = textareaRef.current.scrollTop;
42
+ }, []);
43
+
44
+ // Restore the selection after a fallback (non-execCommand) indent re-render, and keep the
45
+ // gutter aligned when the content height changes.
46
+ React.useLayoutEffect(() => {
47
+ const ta = textareaRef.current;
48
+ if (ta && pendingSelection.current) {
49
+ ta.setSelectionRange(...pendingSelection.current);
50
+ pendingSelection.current = null;
51
+ }
52
+ syncScroll();
53
+ }, [value, syncScroll]);
54
+
55
+ function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
56
+ // Modifier-only presses (e.g. Shift before Shift+Tab) must not cancel a pending Esc.
57
+ if (e.key === "Shift" || e.key === "Control" || e.key === "Alt" || e.key === "Meta") return;
58
+ if (e.key === "Escape") {
59
+ tabEscape.current = true;
60
+ return;
61
+ }
62
+ if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) {
63
+ tabEscape.current = false;
64
+ return;
65
+ }
66
+ if (tabEscape.current) {
67
+ tabEscape.current = false;
68
+ return; // let focus move on
69
+ }
70
+ e.preventDefault();
71
+ const ta = e.currentTarget;
72
+ const old = ta.value;
73
+ const edit = indentText(old, ta.selectionStart, ta.selectionEnd, e.shiftKey);
74
+ if (edit.value === old) return;
75
+
76
+ // Replace only the changed span so the browser's undo history keeps working.
77
+ let a = 0;
78
+ while (a < old.length && a < edit.value.length && old[a] === edit.value[a]) a++;
79
+ let b = 0;
80
+ while (
81
+ b < old.length - a &&
82
+ b < edit.value.length - a &&
83
+ old[old.length - 1 - b] === edit.value[edit.value.length - 1 - b]
84
+ )
85
+ b++;
86
+ const inserted = edit.value.slice(a, edit.value.length - b);
87
+ ta.setSelectionRange(a, old.length - b);
88
+ const ok =
89
+ typeof document.execCommand === "function" &&
90
+ document.execCommand(inserted === "" ? "delete" : "insertText", false, inserted);
91
+ if (ok && ta.value === edit.value) {
92
+ ta.setSelectionRange(edit.start, edit.end);
93
+ } else {
94
+ pendingSelection.current = [edit.start, edit.end];
95
+ onChange(edit.value);
96
+ }
97
+ }
98
+
99
+ return (
100
+ <div
101
+ className={cn(
102
+ "flex overflow-hidden rounded-lg bg-kumo-base ring ring-kumo-line focus-within:ring-kumo-focus/50",
103
+ invalid && "!ring-kumo-danger",
104
+ )}
105
+ >
106
+ <pre
107
+ ref={gutterRef}
108
+ aria-hidden="true"
109
+ className="hfc-code-text m-0 shrink-0 select-none overflow-hidden border-e border-kumo-line bg-kumo-elevated px-3 py-3 text-end font-mono text-kumo-subtle"
110
+ >
111
+ {numbers}
112
+ {"\n\n\n"}
113
+ </pre>
114
+ <textarea
115
+ ref={textareaRef}
116
+ id={id}
117
+ data-testid={id}
118
+ aria-label={label}
119
+ aria-invalid={invalid || undefined}
120
+ aria-describedby={describedBy}
121
+ value={value}
122
+ onChange={(e) => onChange(e.target.value)}
123
+ onKeyDown={onKeyDown}
124
+ onScroll={syncScroll}
125
+ spellCheck={false}
126
+ autoCapitalize="off"
127
+ autoComplete="off"
128
+ autoCorrect="off"
129
+ wrap="off"
130
+ placeholder={"<script>\n // your code\n</script>"}
131
+ className="hfc-code-text hfc-code-area min-w-0 flex-1 resize-y bg-transparent px-3 py-3 font-mono text-kumo-default outline-none"
132
+ />
133
+ </div>
134
+ );
135
+ }