Add inline URL editing for guest rows with click-away protection

Redesigned guest URL management from a bulky settings table to streamlined
inline editing directly on the dashboard:

Features:
- Single-click guest name to edit custom URL
- Text cursor indicates editability
- Inline editor with save (✓) and delete (✕) buttons
- Auto-focus and text selection on edit start
- Tag badges hidden during editing to maximize input space
- Click-away closes editor without activating underlying elements

Technical improvements:
- Global editing state prevents multiple simultaneous edits
- Smart click capture intercepts mousedown/click events when editor is open
- Prevents accidental row expansion or other actions during editing
- Delete button (✕) removes URL and icon entirely
- Escape key closes without saving
- Enter key saves and closes
This commit is contained in:
rcourtman 2025-10-23 17:42:40 +00:00
parent e565b292bf
commit 15252c95ce

View file

@ -177,16 +177,49 @@ export function GuestRow(props: GuestRowProps) {
editingValues.set(guestId(), customUrl() || ''); editingValues.set(guestId(), customUrl() || '');
setEditingValuesVersion(v => v + 1); setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(guestId()); setCurrentlyEditingGuestId(guestId());
// Focus the input after it renders
queueMicrotask(() => {
if (urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
}; };
// Auto-focus the input when editing starts
createEffect(() => {
if (isEditingUrl() && urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
// Add global click handler to close editor and prevent clicks while editing
createEffect(() => {
if (isEditingUrl()) {
const handleGlobalClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// If clicking outside the editor, close it and prevent the click
if (!target.closest('[data-url-editor]') && currentlyEditingGuestId() === guestId()) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
cancelEditingUrl();
}
};
const handleGlobalMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('[data-url-editor]') && currentlyEditingGuestId() === guestId()) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
};
// Use capture phase to intercept clicks before they bubble
document.addEventListener('mousedown', handleGlobalMouseDown, true);
document.addEventListener('click', handleGlobalClick, true);
return () => {
document.removeEventListener('mousedown', handleGlobalMouseDown, true);
document.removeEventListener('click', handleGlobalClick, true);
};
}
});
const saveUrl = async () => { const saveUrl = async () => {
// Only save if this guest is the one being edited // Only save if this guest is the one being edited
if (currentlyEditingGuestId() !== guestId()) return; if (currentlyEditingGuestId() !== guestId()) return;
@ -221,10 +254,39 @@ export function GuestRow(props: GuestRowProps) {
} }
}; };
const deleteUrl = async () => {
// Only process if this guest is the one being edited
if (currentlyEditingGuestId() !== guestId()) return;
// Clear global editing state
editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null);
// If there was a URL set, delete it
if (customUrl()) {
try {
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' });
setCustomUrl(undefined);
// Notify parent to update metadata
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(guestId(), '');
}
showSuccess('Guest URL removed');
} catch (err: any) {
console.error('Failed to remove guest URL:', err);
showError(err.message || 'Failed to remove guest URL');
}
}
};
const cancelEditingUrl = () => { const cancelEditingUrl = () => {
// Only cancel if this guest is the one being edited // Only cancel if this guest is the one being edited
if (currentlyEditingGuestId() !== guestId()) return; if (currentlyEditingGuestId() !== guestId()) return;
// Just close without saving
editingValues.delete(guestId()); editingValues.delete(guestId());
setEditingValuesVersion(v => v + 1); setEditingValuesVersion(v => v + 1);
setCurrentlyEditingGuestId(null); setCurrentlyEditingGuestId(null);
@ -375,7 +437,7 @@ export function GuestRow(props: GuestRowProps) {
</div> </div>
} }
> >
<div class="flex-1 flex items-center gap-1"> <div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
<input <input
ref={urlInputRef} ref={urlInputRef}
type="text" type="text"
@ -385,6 +447,13 @@ export function GuestRow(props: GuestRowProps) {
editingValues.set(guestId(), e.currentTarget.value); editingValues.set(guestId(), e.currentTarget.value);
setEditingValuesVersion(v => v + 1); setEditingValuesVersion(v => v + 1);
}} }}
onBlur={(e) => {
// Close editor when clicking away, but not if clicking the save/delete buttons
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget || !relatedTarget.closest('[data-url-editor-button]')) {
cancelEditingUrl();
}
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
@ -400,38 +469,42 @@ export function GuestRow(props: GuestRowProps) {
/> />
<button <button
type="button" type="button"
data-url-editor-button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
saveUrl(); saveUrl();
}} }}
class="px-2 py-0.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)" title="Save (or press Enter)"
> >
</button> </button>
<button <button
type="button" type="button"
data-url-editor-button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
cancelEditingUrl(); deleteUrl();
}} }}
class="px-2 py-0.5 text-xs bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors" class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Cancel (or press Escape)" title="Delete URL"
> >
</button> </button>
</div> </div>
</Show> </Show>
{/* Tag badges */} {/* Tag badges - hide when editing URL to save space */}
<div class="flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}> <Show when={!isEditingUrl()}>
<TagBadges <div class="flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []} <TagBadges
maxVisible={3} tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
onTagClick={props.onTagClick} maxVisible={3}
activeSearch={props.activeSearch} onTagClick={props.onTagClick}
/> activeSearch={props.activeSearch}
</div> />
</div>
</Show>
<Show when={lockLabel()}> <Show when={lockLabel()}>
<span <span