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:
parent
e565b292bf
commit
15252c95ce
1 changed files with 95 additions and 22 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue