@artooi/ag-ui-web-component 0.32.0 → 0.33.1

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,156 @@
1
+ /** What a copied message puts on the clipboard, in both flavours. */
2
+ export interface CopyPayload {
3
+ /** The plain-text flavour, structured so a table survives a paste. */
4
+ readonly text: string;
5
+ /** The rich flavour, so a table pastes as a table. */
6
+ readonly html: string;
7
+ }
8
+
9
+ /** Elements whose content starts on a line of its own. */
10
+ const BLOCK_TAGS = new Set([
11
+ "ADDRESS",
12
+ "ARTICLE",
13
+ "ASIDE",
14
+ "BLOCKQUOTE",
15
+ "DD",
16
+ "DIV",
17
+ "DL",
18
+ "DT",
19
+ "FIGCAPTION",
20
+ "FIGURE",
21
+ "FOOTER",
22
+ "H1",
23
+ "H2",
24
+ "H3",
25
+ "H4",
26
+ "H5",
27
+ "H6",
28
+ "HEADER",
29
+ "HR",
30
+ "MAIN",
31
+ "NAV",
32
+ "OL",
33
+ "P",
34
+ "SECTION",
35
+ "UL",
36
+ ]);
37
+
38
+ /**
39
+ * What to copy from one rendered message, in both clipboard flavours.
40
+ *
41
+ * `textContent` was the obvious answer and the wrong one. It concatenates every
42
+ * descendant with no separator at all, so a markdown table arrives as one run
43
+ * of digits with the headers welded to the first row -- unusable in a
44
+ * spreadsheet, and unreadable anywhere. Lists lose their bullets the same way,
45
+ * and paragraphs run together.
46
+ *
47
+ * So the plain flavour is serialised structurally: table rows become tab
48
+ * separated lines, which is the format spreadsheets parse on paste, and blocks
49
+ * and list items get their own lines. The rich flavour carries the markup, so a
50
+ * target that understands it -- a document, a chat client, a spreadsheet --
51
+ * gets the real table rather than a reconstruction of one.
52
+ *
53
+ * Both are taken from a **clone with the component's own buttons removed**. The
54
+ * code-block copy buttons are appended inside their own `pre`, so they are
55
+ * descendants of the bubble and `textContent` was picking their label up: a
56
+ * message containing a code block copied with the word for Copy sitting in the
57
+ * middle of it.
58
+ */
59
+ export function copyPayload(root: Element): CopyPayload {
60
+ const clone = root.cloneNode(true) as Element;
61
+ // The component's own affordances, not the message. Markdown cannot produce
62
+ // a button, so anything matching here is chrome this element added.
63
+ for (const control of Array.from(clone.querySelectorAll("button"))) {
64
+ control.remove();
65
+ }
66
+ return { text: normalise(serialise(clone)), html: clone.innerHTML };
67
+ }
68
+
69
+ /**
70
+ * Walk a subtree into text, inserting newlines where the layout implies them.
71
+ *
72
+ * Nothing recurses into a `pre` or a table: both are handled whole, because
73
+ * their text is their layout. That is also why this needs no notion of being
74
+ * inside preformatted content -- there is no such position to be in.
75
+ */
76
+ function serialise(node: Node): string {
77
+ if (node.nodeType === Node.TEXT_NODE) {
78
+ // Non-null for a text node; nodeValue is nullable only on document and
79
+ // doctype nodes, which cannot appear inside a message. Outside a pre, runs
80
+ // of whitespace collapse to one space -- the same collapsing the renderer
81
+ // does, so what is copied matches what is read.
82
+ return (node.nodeValue as string).replace(/\s+/g, " ");
83
+ }
84
+ if (node.nodeType !== Node.ELEMENT_NODE) {
85
+ return "";
86
+ }
87
+ const element = node as Element;
88
+ const tag = element.tagName;
89
+ if (tag === "BR") {
90
+ return "\n";
91
+ }
92
+ if (tag === "PRE") {
93
+ // Verbatim: its whitespace is its content.
94
+ return `\n\n${text(element)}\n\n`;
95
+ }
96
+ if (tag === "TABLE") {
97
+ return `\n\n${tableText(element)}\n\n`;
98
+ }
99
+ if (tag === "UL" || tag === "OL") {
100
+ return `\n\n${listText(element, tag === "OL")}\n\n`;
101
+ }
102
+ const inner = children(element);
103
+ return BLOCK_TAGS.has(tag) ? `\n\n${inner}\n\n` : inner;
104
+ }
105
+
106
+ /** Every child of `element`, serialised and joined. */
107
+ function children(element: Element): string {
108
+ let joined = "";
109
+ for (const child of Array.from(element.childNodes)) {
110
+ joined += serialise(child);
111
+ }
112
+ return joined;
113
+ }
114
+
115
+ /**
116
+ * A list, one item per line, marked the way it is rendered.
117
+ *
118
+ * Built here rather than at the item, so the marker comes from the list that
119
+ * owns it and never from an item's parent lookup -- which has a null arm no
120
+ * message can produce.
121
+ */
122
+ function listText(list: Element, numbered: boolean): string {
123
+ return Array.from(list.children)
124
+ .map((item, index) => `${numbered ? `${index + 1}. ` : "- "}${children(item).trim()}`)
125
+ .join("\n");
126
+ }
127
+
128
+ /**
129
+ * A table as tab-separated rows.
130
+ *
131
+ * Tabs rather than a drawn grid because that is what spreadsheets parse: a
132
+ * paste of tab-separated lines lands one value per cell, which is the whole
133
+ * point of copying a table out of an answer.
134
+ */
135
+ function tableText(table: Element): string {
136
+ return Array.from(table.querySelectorAll("tr"))
137
+ .map((row) =>
138
+ Array.from(row.children)
139
+ .map((cell) => text(cell).replace(/\s+/g, " ").trim())
140
+ .join("\t"),
141
+ )
142
+ .join("\n");
143
+ }
144
+
145
+ /** An element's text. Never null: only documents and doctypes have none. */
146
+ function text(element: Element): string {
147
+ return element.textContent as string;
148
+ }
149
+
150
+ /** Tidy the block boundaries the walk inserted generously. */
151
+ function normalise(text: string): string {
152
+ return text
153
+ .replace(/[^\S\n]+\n/g, "\n")
154
+ .replace(/\n{3,}/g, "\n\n")
155
+ .trim();
156
+ }
@@ -0,0 +1,182 @@
1
+ import { clampLauncher } from "./clamp_launcher.js";
2
+ import type { Extent, LauncherBox } from "./launcher_placement.js";
3
+
4
+ /** What the drag needs from its host to do its job. */
5
+ export interface LauncherDragOptions {
6
+ /**
7
+ * Whether the current placement lets the launcher move, read per interaction
8
+ * because `placement` is a live attribute. A docked rail and a full-bleed
9
+ * layout have nowhere to move it to.
10
+ */
11
+ readonly enabled: () => boolean;
12
+ /** The launcher's current box, in viewport coordinates. */
13
+ readonly rect: () => LauncherBox;
14
+ /** The viewport the launcher has to stay inside. */
15
+ readonly viewport: () => Extent;
16
+ /** Put the launcher's top-left at this point. Called per pointer move. */
17
+ readonly apply: (left: number, top: number) => void;
18
+ /**
19
+ * Called once per completed move, for persistence: on `pointerup` for a
20
+ * drag, and when the key comes up (or focus leaves) for a key press. Never
21
+ * per pointer move, and never per key repeat.
22
+ */
23
+ readonly commit: (left: number, top: number) => void;
24
+ }
25
+
26
+ /**
27
+ * How far the pointer must travel before this counts as a drag rather than a
28
+ * click. The launcher's first job is still to expand the panel, so a press
29
+ * that wanders by a pixel has to remain a press.
30
+ */
31
+ const DRAG_THRESHOLD = 4;
32
+
33
+ /** Keyboard step, and the larger one Shift asks for. */
34
+ const STEP = 16;
35
+ const COARSE_STEP = 64;
36
+
37
+ /**
38
+ * Let the user drag the collapsed launcher anywhere on screen.
39
+ *
40
+ * The launcher is a button before it is a handle, and that ordering is the
41
+ * whole difficulty: every drag ends in a `click` the browser synthesises, which
42
+ * would expand the panel the user was only repositioning. So a completed drag
43
+ * arms a one-shot capture listener that eats exactly one click.
44
+ *
45
+ * Two things stop that from eating a click it should not have. A drag ending
46
+ * off the launcher never produces the click it armed, so the arming is cleared
47
+ * on the next `pointerdown` -- a pointer click is always preceded by a press,
48
+ * which puts the clearing ahead of any click it could reach.
49
+ *
50
+ * That leaves the clicks with no press in front of them at all: `Enter` and
51
+ * `Space` on the focused button, assistive technology activating it, and a host
52
+ * calling `click()` itself. None of those is the tail of a drag, and all of them
53
+ * are the only way in for someone not using a pointer, so the suppression is
54
+ * narrowed to clicks carrying a click count -- which is the pointer's own.
55
+ */
56
+ export function enableLauncherDrag(launcher: HTMLElement, options: LauncherDragOptions): void {
57
+ let suppressClick = false;
58
+
59
+ launcher.addEventListener(
60
+ "click",
61
+ (event: MouseEvent) => {
62
+ // detail is the click count on a pointer click and zero on a keyboard,
63
+ // assistive or programmatic one. Only the first kind can end a drag.
64
+ if (!suppressClick || event.detail === 0) {
65
+ return;
66
+ }
67
+ suppressClick = false;
68
+ // Capture phase, so this runs before the expand handler bound to the
69
+ // same element rather than after it.
70
+ event.stopPropagation();
71
+ event.preventDefault();
72
+ },
73
+ true,
74
+ );
75
+
76
+ launcher.addEventListener("pointerdown", (event: PointerEvent) => {
77
+ // Any suppression left armed by a drag that ended elsewhere dies here,
78
+ // before it can reach a click belonging to this new gesture.
79
+ suppressClick = false;
80
+ if (!options.enabled()) {
81
+ return;
82
+ }
83
+ const start = options.rect();
84
+ const originX = event.clientX;
85
+ const originY = event.clientY;
86
+ let dragging = false;
87
+
88
+ const onMove = (move: PointerEvent): void => {
89
+ const dx = move.clientX - originX;
90
+ const dy = move.clientY - originY;
91
+ if (!dragging && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
92
+ return;
93
+ }
94
+ dragging = true;
95
+ launcher.setAttribute("data-dragging", "true");
96
+ // Measured from the box the press started on, never from the live one:
97
+ // reading it each move would chase the launcher as it moves and the
98
+ // travel would compound.
99
+ const next = clampLauncher(
100
+ { ...start, left: start.left + dx, top: start.top + dy },
101
+ options.viewport(),
102
+ );
103
+ options.apply(next.left, next.top);
104
+ };
105
+
106
+ const onUp = (up: PointerEvent): void => {
107
+ window.removeEventListener("pointermove", onMove);
108
+ window.removeEventListener("pointerup", onUp);
109
+ if (!dragging) {
110
+ return;
111
+ }
112
+ launcher.removeAttribute("data-dragging");
113
+ suppressClick = true;
114
+ const next = clampLauncher(
115
+ {
116
+ ...start,
117
+ left: start.left + (up.clientX - originX),
118
+ top: start.top + (up.clientY - originY),
119
+ },
120
+ options.viewport(),
121
+ );
122
+ options.commit(next.left, next.top);
123
+ };
124
+
125
+ // Listeners on `window`, not the launcher: a fast drag outruns the pointer
126
+ // and would otherwise strand it mid-move with no pointerup.
127
+ window.addEventListener("pointermove", onMove);
128
+ window.addEventListener("pointerup", onUp);
129
+ });
130
+
131
+ // The position this key gesture has applied but not yet persisted. The
132
+ // pointer path can commit inline because a drag has one unambiguous end; a
133
+ // key press does not, so the gesture's result is held here until it does.
134
+ let pending: { left: number; top: number } | null = null;
135
+
136
+ /** End a key gesture: persist what it applied, once. */
137
+ const settle = (): void => {
138
+ if (pending === null) {
139
+ return;
140
+ }
141
+ const { left, top } = pending;
142
+ pending = null;
143
+ options.commit(left, top);
144
+ };
145
+
146
+ // Keyboard parity: a pointer-only move is unreachable without a mouse, and
147
+ // the launcher has no equivalent control elsewhere. Arrow keys are free on a
148
+ // button, so Enter and Space still expand.
149
+ launcher.addEventListener("keydown", (event: KeyboardEvent) => {
150
+ if (!options.enabled()) {
151
+ return;
152
+ }
153
+ const step = event.shiftKey ? COARSE_STEP : STEP;
154
+ const rect = options.rect();
155
+ let moved: { left: number; top: number } | null = null;
156
+ if (event.key === "ArrowLeft") {
157
+ moved = { left: rect.left - step, top: rect.top };
158
+ } else if (event.key === "ArrowRight") {
159
+ moved = { left: rect.left + step, top: rect.top };
160
+ } else if (event.key === "ArrowUp") {
161
+ moved = { left: rect.left, top: rect.top - step };
162
+ } else if (event.key === "ArrowDown") {
163
+ moved = { left: rect.left, top: rect.top + step };
164
+ }
165
+ if (moved === null) {
166
+ return;
167
+ }
168
+ event.preventDefault();
169
+ const next = clampLauncher({ ...rect, ...moved }, options.viewport());
170
+ // Live feedback per key event, persistence only when the gesture ends: a
171
+ // held arrow repeats at the OS rate, and committing here would put that
172
+ // many storage writes behind a single press.
173
+ options.apply(next.left, next.top);
174
+ pending = next;
175
+ });
176
+
177
+ // The key coming up ends the gesture, mirroring `pointerup`. `blur` closes
178
+ // one whose keyup never arrives here -- focus moved on mid-press -- because a
179
+ // position applied but never committed is a move the host silently forgets.
180
+ launcher.addEventListener("keyup", settle);
181
+ launcher.addEventListener("blur", settle);
182
+ }
@@ -0,0 +1,143 @@
1
+ /** A box in viewport coordinates. */
2
+ export interface LauncherBox {
3
+ readonly left: number;
4
+ readonly top: number;
5
+ readonly width: number;
6
+ readonly height: number;
7
+ }
8
+
9
+ /** A width/height pair, in CSS pixels. */
10
+ export interface Extent {
11
+ readonly width: number;
12
+ readonly height: number;
13
+ }
14
+
15
+ /**
16
+ * The corner the panel is pinned by, and therefore grows away from: pinned
17
+ * `top-left` opens down and to the right. It is also the corner the resize grip
18
+ * must stay off, which is why the element stamps it rather than measuring it
19
+ * once the launcher has been moved.
20
+ */
21
+ export interface ExpandCorner {
22
+ readonly x: "left" | "right";
23
+ readonly y: "top" | "bottom";
24
+ }
25
+
26
+ /** Where to put the host box, and where the launcher sits inside it. */
27
+ export interface LauncherPlacement {
28
+ /** The corner the panel opens away from. */
29
+ readonly corner: ExpandCorner;
30
+ /** An `inset` shorthand for the host box. */
31
+ readonly hostInset: string;
32
+ /** An `inset` shorthand for the launcher, relative to the host box. */
33
+ readonly launcherInset: string;
34
+ }
35
+
36
+ /**
37
+ * The gutter a panel keeps from the viewport edge, matching the default
38
+ * `--ag-ui-inset` so an undragged widget resolves to exactly the placement it
39
+ * already had. Changing this moves every clamped panel.
40
+ */
41
+ const EDGE_MARGIN = 24;
42
+
43
+ /**
44
+ * Decide where a panel should open from a launcher the user has dragged.
45
+ *
46
+ * The launcher is the fixed point: wherever it was dropped is where it stays,
47
+ * collapsed and expanded alike. Everything here decides what happens *around*
48
+ * it.
49
+ *
50
+ * Two steps, and they are separate on purpose:
51
+ *
52
+ * 1. **Pick the corner with the most clear space.** For each axis, compare the
53
+ * room a panel would have on either side of the launcher and pin the side
54
+ * with more of it. Pinning `left` means the panel runs rightward from the
55
+ * launcher's left edge, so the room that decides it is the distance from
56
+ * that edge to the far side of the viewport -- not "which half of the screen
57
+ * is the launcher in", which gives a different and worse answer for a
58
+ * launcher near the middle.
59
+ *
60
+ * 2. **Clamp the panel into the viewport, then put the launcher back.** A
61
+ * launcher near the centre of a short viewport has a panel taller than
62
+ * either side, so the winning corner still overflows. Clamping moves the
63
+ * host box -- and the launcher is positioned inside that box, so it would be
64
+ * dragged along with it. The launcher's own inset therefore carries the
65
+ * difference: zero in the ordinary case, and exactly the distance the clamp
66
+ * moved the box in the case that needs it. That the launcher may end up
67
+ * outside its own host box is fine; nothing clips it, and it keeps its own
68
+ * pointer events.
69
+ *
70
+ * Both insets are expressed from the *same* corner, which is what stops a
71
+ * later panel resize from dragging the launcher: the pinned corner is the one
72
+ * edge a resize cannot move.
73
+ */
74
+ export function launcherPlacement(
75
+ launcher: LauncherBox,
76
+ panel: Extent,
77
+ viewport: Extent,
78
+ margin: number = EDGE_MARGIN,
79
+ ): LauncherPlacement {
80
+ // Room for a panel pinned to each side of the launcher. A tie goes to the
81
+ // first branch, so the result is deterministic for a centred launcher.
82
+ const roomRunningRight = viewport.width - launcher.left;
83
+ const roomRunningLeft = launcher.left + launcher.width;
84
+ const roomRunningDown = viewport.height - launcher.top;
85
+ const roomRunningUp = launcher.top + launcher.height;
86
+ const corner: ExpandCorner = {
87
+ x: roomRunningRight >= roomRunningLeft ? "left" : "right",
88
+ y: roomRunningDown >= roomRunningUp ? "top" : "bottom",
89
+ };
90
+
91
+ // The box the panel wants, then the box it can actually have. Clamping the
92
+ // near edge covers the far edge too: the panel is a fixed size here, and a
93
+ // panel too large for the viewport is held against the near margin and left
94
+ // to the max-width and max-height rules rather than centred by force.
95
+ const wantedLeft =
96
+ corner.x === "left" ? launcher.left : launcher.left + launcher.width - panel.width;
97
+ const wantedTop =
98
+ corner.y === "top" ? launcher.top : launcher.top + launcher.height - panel.height;
99
+ const hostLeft = clamp(wantedLeft, margin, viewport.width - margin - panel.width);
100
+ const hostTop = clamp(wantedTop, margin, viewport.height - margin - panel.height);
101
+
102
+ const hostRight = hostLeft + panel.width;
103
+ const hostBottom = hostTop + panel.height;
104
+
105
+ return {
106
+ corner,
107
+ hostInset: inset({
108
+ top: corner.y === "top" ? hostTop : null,
109
+ right: corner.x === "right" ? viewport.width - hostRight : null,
110
+ bottom: corner.y === "bottom" ? viewport.height - hostBottom : null,
111
+ left: corner.x === "left" ? hostLeft : null,
112
+ }),
113
+ // Measured from the host box's pinned corner to the launcher's matching
114
+ // corner, so the launcher lands exactly where it was dropped.
115
+ launcherInset: inset({
116
+ top: corner.y === "top" ? launcher.top - hostTop : null,
117
+ right: corner.x === "right" ? hostRight - (launcher.left + launcher.width) : null,
118
+ bottom: corner.y === "bottom" ? hostBottom - (launcher.top + launcher.height) : null,
119
+ left: corner.x === "left" ? launcher.left - hostLeft : null,
120
+ }),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Constrain a value, with the lower bound winning a contradiction. `Math.min`
126
+ * first would put a panel wider than the viewport off the left edge instead of
127
+ * against the near margin.
128
+ */
129
+ function clamp(value: number, low: number, high: number): number {
130
+ return Math.max(low, Math.min(value, high));
131
+ }
132
+
133
+ /** An `inset` shorthand; a null side is `auto`, so the opposite one pins it. */
134
+ function inset(sides: {
135
+ top: number | null;
136
+ right: number | null;
137
+ bottom: number | null;
138
+ left: number | null;
139
+ }): string {
140
+ const side = (value: number | null): string =>
141
+ value === null ? "auto" : `${Math.round(value)}px`;
142
+ return `${side(sides.top)} ${side(sides.right)} ${side(sides.bottom)} ${side(sides.left)}`;
143
+ }
@@ -1,3 +1,4 @@
1
+ import { ICON_COPY, ICON_THUMB_DOWN, ICON_THUMB_UP } from "../constants.js";
1
2
  import type { UiStrings } from "./ui_strings.js";
2
3
 
3
4
  /** How long a button shows its confirmation before reverting. */
@@ -18,6 +19,17 @@ export interface MessageActionsOptions {
18
19
  * disagree.
19
20
  */
20
21
  text?: () => string;
22
+ /**
23
+ * The rich flavour to copy alongside `text`, as an HTML fragment. Absent
24
+ * means the clipboard gets plain text only, which is what this did before
25
+ * the option existed.
26
+ *
27
+ * It is what makes a copied table paste as a table: a spreadsheet or a chat
28
+ * client reads `text/html` when it is offered and falls back to the plain
29
+ * flavour when it is not, so the two are the same content at two fidelities
30
+ * rather than a choice the caller has to make.
31
+ */
32
+ html?: () => string;
21
33
  /**
22
34
  * Report a rating for this message. Absent means no feedback buttons.
23
35
  *
@@ -53,7 +65,7 @@ export function attachMessageActions(bubble: HTMLElement, options: MessageAction
53
65
  const bar = messageActionBar(bubble, options.strings);
54
66
  const text = options.text;
55
67
  if (text !== undefined) {
56
- bar.appendChild(copyButton(options.strings, text));
68
+ bar.appendChild(copyButton(options.strings, text, options.html));
57
69
  }
58
70
  if (options.onFeedback !== undefined) {
59
71
  bar.append(
@@ -98,38 +110,102 @@ function existingBar(bubble: HTMLElement): HTMLElement | null {
98
110
  return next?.classList.contains("message-actions") === true ? (next as HTMLElement) : null;
99
111
  }
100
112
 
101
- /** Build one action button, labelled for screen readers rather than by glyph. */
113
+ /**
114
+ * Build one action button, labelled for screen readers rather than by glyph.
115
+ *
116
+ * `icon` is icon markup rather than a text glyph, because the text glyphs these
117
+ * carried are the obscure end of the character set -- the copy mark in
118
+ * particular has no font behind it on most systems, so it rendered as a mark
119
+ * nobody could name on a control small enough that nobody could hit it either.
120
+ *
121
+ * The label is carried three ways, and each has a reader the others miss:
122
+ * `aria-label` for assistive technology, `title` for the browser's own
123
+ * tooltip, and `data-tooltip` for the one this component draws. The last is
124
+ * not redundant with the second -- a `title` never appears on keyboard focus,
125
+ * so without it a keyboard user has no way to see what the control does.
126
+ *
127
+ * The icon sits in its own part, so a host can restyle or replace it. A slot
128
+ * would be the better channel and cannot be used here: these repeat once per
129
+ * message, and a named slot can only be filled once.
130
+ */
102
131
  export function messageActionButton(
103
132
  modifier: string,
104
133
  label: string,
105
- glyph: string,
134
+ icon: string,
106
135
  ): HTMLButtonElement {
107
136
  const button = document.createElement("button");
108
137
  button.type = "button";
109
138
  button.className = `message-action message-action--${modifier}`;
110
139
  button.setAttribute("part", `message-action message-action-${modifier}`);
140
+ setLabel(button, label);
141
+ const holder = document.createElement("span");
142
+ holder.className = "message-action-icon";
143
+ holder.setAttribute("part", `message-action-icon message-action-icon-${modifier}`);
144
+ holder.setAttribute("aria-hidden", "true");
145
+ // Author-written markup from constants, never message content.
146
+ holder.innerHTML = icon;
147
+ button.appendChild(holder);
148
+ return button;
149
+ }
150
+
151
+ /** Put `label` on every channel that names this button. */
152
+ function setLabel(button: HTMLButtonElement, label: string): void {
111
153
  button.title = label;
112
154
  button.setAttribute("aria-label", label);
113
- const icon = document.createElement("span");
114
- icon.setAttribute("aria-hidden", "true");
115
- icon.textContent = glyph;
116
- button.appendChild(icon);
117
- return button;
155
+ button.dataset["tooltip"] = label;
118
156
  }
119
157
 
120
- function copyButton(strings: UiStrings, text: () => string): HTMLButtonElement {
121
- const button = messageActionButton("copy", strings.copyMessage, "⎘");
158
+ function copyButton(
159
+ strings: UiStrings,
160
+ text: () => string,
161
+ html: (() => string) | undefined,
162
+ ): HTMLButtonElement {
163
+ const button = messageActionButton("copy", strings.copyMessage, ICON_COPY);
122
164
  button.addEventListener("click", () => {
123
- void navigator.clipboard.writeText(text()).then(
124
- () => flash(button, strings.copied, strings.copyMessage),
165
+ void write(text(), html?.()).then((ok) => {
125
166
  // A denied clipboard permission is the common case, not an exception:
126
167
  // say so on the button rather than throwing into an unhandled rejection.
127
- () => flash(button, strings.copyFailed, strings.copyMessage),
128
- );
168
+ flash(button, ok ? strings.copied : strings.copyFailed, strings.copyMessage);
169
+ });
129
170
  });
130
171
  return button;
131
172
  }
132
173
 
174
+ /**
175
+ * Put the message on the clipboard, richest flavour first.
176
+ *
177
+ * Writing both flavours needs `ClipboardItem`, which not every engine that has
178
+ * `writeText` also has -- and even where the constructor exists the write can
179
+ * be refused. Neither is a failure worth reporting as one while the plain text
180
+ * would still have landed, so both fall through to `writeText` and only that
181
+ * decides what the button says.
182
+ */
183
+ async function write(text: string, html: string | undefined): Promise<boolean> {
184
+ const clipboard = navigator.clipboard;
185
+ if (clipboard === undefined) {
186
+ return false;
187
+ }
188
+ if (html !== undefined && typeof ClipboardItem === "function") {
189
+ try {
190
+ await clipboard.write([
191
+ new ClipboardItem({
192
+ "text/plain": new Blob([text], { type: "text/plain" }),
193
+ "text/html": new Blob([html], { type: "text/html" }),
194
+ }),
195
+ ]);
196
+ return true;
197
+ } catch {
198
+ // Fall through to the plain flavour.
199
+ }
200
+ }
201
+ try {
202
+ await clipboard.writeText(text);
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
133
209
  function feedbackButton(
134
210
  rating: "up" | "down",
135
211
  label: string,
@@ -138,7 +214,7 @@ function feedbackButton(
138
214
  const button = messageActionButton(
139
215
  rating === "up" ? "up" : "down",
140
216
  label,
141
- rating === "up" ? "\u{1F44D}" : "\u{1F44E}",
217
+ rating === "up" ? ICON_THUMB_UP : ICON_THUMB_DOWN,
142
218
  );
143
219
  button.addEventListener("click", () => {
144
220
  // Pressed rather than removed: the rating is a standing statement about the
@@ -159,12 +235,10 @@ function feedbackButton(
159
235
  * cannot happen and cannot be covered.
160
236
  */
161
237
  function flash(button: HTMLButtonElement, message: string, label: string): void {
162
- button.title = message;
163
- button.setAttribute("aria-label", message);
238
+ setLabel(button, message);
164
239
  button.classList.add("message-action--confirmed");
165
240
  setTimeout(() => {
166
- button.title = label;
167
- button.setAttribute("aria-label", label);
241
+ setLabel(button, label);
168
242
  button.classList.remove("message-action--confirmed");
169
243
  }, CONFIRM_MS);
170
244
  }