Skip to content

Commit ee5b19b

Browse files
authored
feat(ui): add domain truncate component with copy (#107)
1 parent 8f24f2a commit ee5b19b

File tree

8 files changed

+428
-1
lines changed

8 files changed

+428
-1
lines changed

services/merrymaker-go/frontend/biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

services/merrymaker-go/frontend/public/js/app.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import "./components/filter-pills.js";
99
import "./components/toast.js";
1010
import "./components/row-delete.js";
1111
import "./components/ioc-form.js";
12+
import "./components/domain-truncate.js";
1213
import "./job_status.js";
1314
import "./source_form.js";
1415
import { bootFeatures } from "./features/index.js";
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* Domain Truncate Component
3+
*
4+
* Provides interactive features for long domain names:
5+
* - Click-to-copy domain value with visual feedback
6+
* - Expand/collapse toggle for truncated domains
7+
* - Native title tooltip for full domain on hover
8+
*
9+
* IMPORTANT: Markup Considerations
10+
* This component works best when domain-text is NOT nested inside <a> tags.
11+
* If used within links (as in alert item rows), ensure:
12+
* 1. Parent links have hx-boost or HTMX event handlers (not standard click)
13+
* 2. Button events use stopPropagation() to prevent link navigation
14+
* 3. Consider moving buttons outside link structure for better semantics
15+
*
16+
* Usage:
17+
* Add data-domain-truncate attribute to domain elements in templates:
18+
* <span class="domain-text" data-domain-truncate="full.domain.name.here">
19+
* truncated.domain...
20+
* </span>
21+
*/
22+
23+
import { Lifecycle } from "../core/lifecycle.js";
24+
25+
/**
26+
* Copy text to clipboard with fallback for older browsers
27+
* @param {string} text - Text to copy
28+
* @returns {Promise<boolean>} - Resolves to true if successful
29+
*/
30+
async function copyToClipboard(text) {
31+
// Try modern Clipboard API first
32+
if (navigator.clipboard?.writeText) {
33+
try {
34+
await navigator.clipboard.writeText(text);
35+
return true;
36+
} catch (err) {
37+
console.error("Clipboard API failed:", err);
38+
// Fall through to fallback
39+
}
40+
}
41+
42+
// Fallback: use deprecated execCommand
43+
try {
44+
const textarea = document.createElement("textarea");
45+
textarea.value = text;
46+
textarea.style.position = "fixed";
47+
textarea.style.opacity = "0";
48+
document.body.appendChild(textarea);
49+
50+
textarea.select();
51+
textarea.setSelectionRange(0, text.length);
52+
53+
const success = document.execCommand("copy");
54+
document.body.removeChild(textarea);
55+
56+
return success;
57+
} catch (err) {
58+
console.error("Fallback copy failed:", err);
59+
return false;
60+
}
61+
}
62+
63+
/**
64+
* Initialize domain truncation features for an element
65+
* @param {HTMLElement} element - The domain text element
66+
*/
67+
function initDomainElement(element) {
68+
const fullDomain = element.getAttribute("data-domain-truncate");
69+
if (!fullDomain) return;
70+
71+
// Add title attribute for native browser tooltip
72+
element.title = fullDomain;
73+
74+
// Wrap in container if not already wrapped
75+
let wrapper = element.closest(".domain-wrapper");
76+
if (!wrapper) {
77+
wrapper = document.createElement("span");
78+
wrapper.className = "domain-wrapper";
79+
element.parentNode.insertBefore(wrapper, element);
80+
wrapper.appendChild(element);
81+
}
82+
83+
// Mark parent with domain actions for CSS styling
84+
const parent = wrapper.parentElement;
85+
if (parent) {
86+
parent.classList.add("has-domain-actions");
87+
}
88+
89+
// Add copy button if not exists
90+
if (!wrapper.querySelector(".btn-copy-domain")) {
91+
const copyBtn = createCopyButton(fullDomain);
92+
wrapper.appendChild(copyBtn);
93+
}
94+
95+
// Check if domain is actually truncated on screen after layout
96+
requestAnimationFrame(() => {
97+
const needsExpand = element.scrollWidth > element.clientWidth;
98+
if (needsExpand && !wrapper.querySelector(".btn-expand-domain")) {
99+
const expandBtn = createExpandButton(element);
100+
wrapper.appendChild(expandBtn);
101+
}
102+
});
103+
}
104+
105+
/**
106+
* Create copy button with Lucide icon
107+
* @param {string} textToCopy - The full domain text to copy
108+
* @returns {HTMLButtonElement}
109+
*/
110+
function createCopyButton(textToCopy) {
111+
const button = document.createElement("button");
112+
button.type = "button";
113+
button.className = "btn-copy-domain";
114+
button.setAttribute("aria-label", "Copy domain to clipboard");
115+
button.setAttribute("data-domain-value", textToCopy);
116+
button.title = "Copy domain";
117+
118+
const icon = document.createElement("i");
119+
icon.setAttribute("data-lucide", "copy");
120+
button.appendChild(icon);
121+
122+
return button;
123+
}
124+
125+
/**
126+
* Create expand/collapse toggle button
127+
* @param {HTMLElement} domainElement - The domain text element to expand
128+
* @returns {HTMLButtonElement}
129+
*/
130+
function createExpandButton(domainElement) {
131+
const button = document.createElement("button");
132+
button.type = "button";
133+
button.className = "btn-expand-domain";
134+
button.setAttribute("aria-label", "Expand domain");
135+
button.setAttribute("aria-expanded", "false");
136+
button.setAttribute("data-domain-element", "");
137+
button.title = "Expand domain";
138+
139+
const icon = document.createElement("i");
140+
icon.setAttribute("data-lucide", "chevron-down");
141+
button.appendChild(icon);
142+
143+
// Store reference to domain element for event delegation
144+
button.__domainElement = domainElement;
145+
146+
return button;
147+
}
148+
149+
/**
150+
* Show visual feedback for successful copy
151+
* Uses CSS class only - no icon swap needed
152+
* @param {HTMLButtonElement} button
153+
*/
154+
function showCopySuccess(button) {
155+
button.classList.add("is-copied");
156+
button.setAttribute("aria-label", "Copied!");
157+
button.title = "Copied!";
158+
159+
// Announce to screen readers
160+
const domainValue = button.getAttribute("data-domain-value");
161+
announceToScreenReaders(`${domainValue} copied to clipboard`);
162+
163+
setTimeout(() => {
164+
button.classList.remove("is-copied");
165+
button.setAttribute("aria-label", "Copy domain to clipboard");
166+
button.title = "Copy domain";
167+
}, COPY_FEEDBACK_DURATION);
168+
}
169+
170+
/**
171+
* Show visual feedback for copy error
172+
* @param {HTMLButtonElement} button
173+
*/
174+
function showCopyError(button) {
175+
button.classList.add("is-error");
176+
setTimeout(() => {
177+
button.classList.remove("is-error");
178+
}, COPY_FEEDBACK_DURATION);
179+
}
180+
181+
/**
182+
* Handle delegated click events for copy and expand buttons
183+
* Uses stopPropagation to prevent parent link navigation when nested
184+
* @param {Event} e
185+
*/
186+
function handleDomainButtonClick(e) {
187+
const button = e.target.closest(".btn-copy-domain, .btn-expand-domain");
188+
if (!button) return;
189+
190+
// CRITICAL: Stop propagation to prevent parent link/row navigation
191+
// This is especially important when buttons are nested inside <a> or hx-boost elements
192+
e.preventDefault();
193+
e.stopPropagation();
194+
195+
if (button.classList.contains("btn-copy-domain")) {
196+
const textToCopy = button.getAttribute("data-domain-value");
197+
if (textToCopy) {
198+
copyToClipboard(textToCopy).then((success) => {
199+
if (success) {
200+
showCopySuccess(button);
201+
} else {
202+
showCopyError(button);
203+
}
204+
});
205+
}
206+
} else if (button.classList.contains("btn-expand-domain")) {
207+
const domainElement = button.__domainElement;
208+
if (domainElement) {
209+
const isExpanded = domainElement.classList.toggle("is-expanded");
210+
button.classList.toggle("is-expanded", isExpanded);
211+
button.setAttribute("aria-expanded", isExpanded.toString());
212+
button.setAttribute("aria-label", isExpanded ? "Collapse domain" : "Expand domain");
213+
button.title = isExpanded ? "Collapse domain" : "Expand domain";
214+
}
215+
}
216+
}
217+
218+
/**
219+
* Initialize all domain elements in the document or container
220+
* @param {HTMLElement} container - Container to search for domain elements
221+
*/
222+
function initDomainTruncate(container = document) {
223+
const elements = container.querySelectorAll("[data-domain-truncate]");
224+
elements.forEach(initDomainElement);
225+
226+
// Re-render Lucide icons after adding new buttons
227+
try {
228+
if (window.lucide?.createIcons) {
229+
window.lucide.createIcons({ icons: window.lucide.icons });
230+
}
231+
} catch (_) {
232+
/* noop */
233+
}
234+
}
235+
236+
// Add single delegated listener for all domain buttons
237+
document.addEventListener("click", handleDomainButtonClick, true);
238+
239+
// Register with lifecycle system for htmx content swaps
240+
Lifecycle.register(
241+
"domain-truncate",
242+
(element) => {
243+
initDomainTruncate(element);
244+
},
245+
() => {
246+
// No cleanup needed - buttons are removed with DOM
247+
},
248+
);
249+
250+
// Initial load
251+
document.addEventListener("DOMContentLoaded", () => {
252+
initDomainTruncate();
253+
});

0 commit comments

Comments
 (0)