feat(field-editor): add display_modal mode with placement, blocking toggle, and unsaved-changes guard
- New `display_modal` prop opens the edit panel as a native <dialog> anchored
near the pencil trigger instead of shifting inline content
- `modal_placement` (center|above|below|left|right, default center) positions
the dialog relative to the trigger via getBoundingClientRect + CSS transform
- `modal_blocking` (default true) toggles showModal() vs show(); non-modal
mode adds a document pointerdown listener to close on outside click
- `cancel_edit()` now warns "Discard unsaved changes?" when draft differs from
saved value (matches data store form behaviour); skips warning after a
successful save
- Dialog background uses theme CSS vars directly (--color-surface-50/900) via
:global CSS — Skeleton tonal presets are intentionally semi-transparent and
rendered behind table content without explicit position:fixed + z-index
- Extracted edit panel to {#snippet edit_panel()} — shared by inline and
dialog paths with no duplication
- data-stores table: all three inline field editors switched to display_modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,9 @@ interface Props {
|
||||
edit_label?: string;
|
||||
display_block?: boolean;
|
||||
display_absolute_edit?: boolean;
|
||||
display_modal?: boolean;
|
||||
modal_blocking?: boolean;
|
||||
modal_placement?: 'center' | 'above' | 'below' | 'left' | 'right';
|
||||
placeholder?: string;
|
||||
class_li?: string;
|
||||
textarea_rows?: number;
|
||||
@@ -92,6 +95,9 @@ let {
|
||||
edit_label = 'Edit Field',
|
||||
display_block = false,
|
||||
display_absolute_edit = false,
|
||||
display_modal = false,
|
||||
modal_blocking = true,
|
||||
modal_placement = 'center' as 'center' | 'above' | 'below' | 'left' | 'right',
|
||||
placeholder = 'Enter value...',
|
||||
class_li = '',
|
||||
textarea_rows = 4,
|
||||
@@ -159,6 +165,9 @@ let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
let error_message = $state('');
|
||||
let draft_value = $state(to_input_value(current_value, field_type));
|
||||
let input_ref = $state<HTMLElement | null>(null);
|
||||
let dialog_ref = $state<HTMLDialogElement | null>(null);
|
||||
let trigger_ref = $state<HTMLElement | null>(null);
|
||||
let dialog_style = $state('');
|
||||
|
||||
// Optimistic display state machine
|
||||
let has_optimistic = $state(false);
|
||||
@@ -190,6 +199,31 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Open/close the <dialog> in modal mode
|
||||
$effect(() => {
|
||||
if (!display_modal || !dialog_ref) return;
|
||||
if (is_editing) {
|
||||
untrack(() => {
|
||||
if (!dialog_ref!.open) {
|
||||
if (modal_blocking) dialog_ref!.showModal();
|
||||
else dialog_ref!.show();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
untrack(() => { if (dialog_ref!.open) dialog_ref!.close(); });
|
||||
}
|
||||
});
|
||||
|
||||
// Non-modal: close when clicking outside the dialog (no backdrop to click)
|
||||
$effect(() => {
|
||||
if (!display_modal || modal_blocking || !is_editing) return;
|
||||
function on_outside(e: PointerEvent) {
|
||||
if (dialog_ref && !dialog_ref.contains(e.target as Node)) cancel_edit();
|
||||
}
|
||||
document.addEventListener('pointerdown', on_outside);
|
||||
return () => document.removeEventListener('pointerdown', on_outside);
|
||||
});
|
||||
|
||||
async function handle_patch() {
|
||||
if (log_lvl) console.log(`AE Field Editor (new): Patching ${object_type}.${field_name}...`);
|
||||
|
||||
@@ -230,6 +264,8 @@ async function handle_patch() {
|
||||
}
|
||||
|
||||
function cancel_edit() {
|
||||
const value_changed = draft_value !== to_input_value(current_value, field_type);
|
||||
if (value_changed && patch_status !== 'success' && !confirm('Discard unsaved changes?')) return;
|
||||
has_optimistic = false;
|
||||
draft_value = to_input_value(current_value, field_type);
|
||||
is_editing = false;
|
||||
@@ -237,11 +273,36 @@ function cancel_edit() {
|
||||
error_message = '';
|
||||
}
|
||||
|
||||
function calc_dialog_pos() {
|
||||
if (!trigger_ref) return;
|
||||
const rect = trigger_ref.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
|
||||
let top: number, left: number, tx: string, ty: string;
|
||||
switch (modal_placement) {
|
||||
case 'above':
|
||||
top = rect.top - gap; left = cx; tx = '-50%'; ty = '-100%'; break;
|
||||
case 'below':
|
||||
top = rect.bottom + gap; left = cx; tx = '-50%'; ty = '0'; break;
|
||||
case 'left':
|
||||
top = cy; left = rect.left - gap; tx = '-100%'; ty = '-50%'; break;
|
||||
case 'right':
|
||||
top = cy; left = rect.right + gap; tx = '0'; ty = '-50%'; break;
|
||||
default: // center
|
||||
top = cy; left = cx; tx = '-50%'; ty = '-50%'; break;
|
||||
}
|
||||
|
||||
dialog_style = `margin:0;top:${top}px;left:${left}px;transform:translate(${tx},${ty});`;
|
||||
}
|
||||
|
||||
function toggle_edit() {
|
||||
if (is_editing) cancel_edit();
|
||||
else {
|
||||
has_optimistic = false;
|
||||
draft_value = to_input_value(current_value, field_type);
|
||||
if (display_modal) calc_dialog_pos();
|
||||
is_editing = true;
|
||||
if (on_open) on_open();
|
||||
}
|
||||
@@ -252,15 +313,160 @@ function handle_keydown(e: KeyboardEvent) {
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet edit_panel()}
|
||||
<header class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-bold tracking-wider uppercase opacity-60">
|
||||
{edit_label || field_name}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon btn-icon-sm preset-tonal-surface"
|
||||
onclick={cancel_edit}
|
||||
aria-label="Cancel editing"
|
||||
title="Cancel editing"
|
||||
disabled={patch_status === 'processing'}>
|
||||
<X size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="input_container mb-3">
|
||||
{#if field_type === 'textarea'}
|
||||
<textarea
|
||||
{id}
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
rows={textarea_rows}
|
||||
class="textarea"
|
||||
{placeholder}></textarea>
|
||||
{:else if field_type === 'select'}
|
||||
<div class="relative">
|
||||
<select
|
||||
{id}
|
||||
bind:this={input_ref}
|
||||
value={draft_value}
|
||||
onchange={(e) => draft_value = coerce_select_value(e.currentTarget.value, current_value)}
|
||||
class="select pr-10">
|
||||
{#if allow_null}
|
||||
<option value={null}>-- None --</option>
|
||||
{/if}
|
||||
{#if Object.keys(select_options).length === 0}
|
||||
<option value="" disabled>Loading options...</option>
|
||||
{/if}
|
||||
{#each Object.entries(select_options) as [val, label] (val)}
|
||||
<option value={val}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if Object.keys(select_options).length === 0}
|
||||
<div class="absolute inset-y-0 right-8 flex items-center">
|
||||
<LoaderCircle size="14" class="animate-spin opacity-50" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if field_type === 'checkbox'}
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
{id}
|
||||
type="checkbox"
|
||||
bind:this={input_ref}
|
||||
bind:checked={draft_value}
|
||||
class="checkbox" />
|
||||
<span>{draft_value ? 'True' : 'False'}</span>
|
||||
</label>
|
||||
{:else if field_type === 'tiptap'}
|
||||
<AE_Comp_Editor_TipTap
|
||||
bind:content={draft_value}
|
||||
{placeholder} />
|
||||
{:else if field_type === 'codemirror'}
|
||||
<AE_Comp_Editor_CodeMirror
|
||||
bind:content={draft_value}
|
||||
{placeholder} />
|
||||
{:else if field_type === 'date'}
|
||||
<input
|
||||
{id}
|
||||
type="date"
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
class="input" />
|
||||
{:else if field_type === 'datetime'}
|
||||
<input
|
||||
{id}
|
||||
type="datetime-local"
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
class="input" />
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
type={field_type === 'number' ? 'number' : field_type === 'email' ? 'email' : field_type === 'url' ? 'url' : field_type === 'tel' ? 'tel' : 'text'}
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
class="input"
|
||||
{placeholder}
|
||||
onkeydown={(e) => e.key === 'Enter' && handle_patch()} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="flex items-center justify-between">
|
||||
<div class="status_indicator text-xs">
|
||||
{#if patch_status === 'processing'}
|
||||
<span class="text-primary-500 flex items-center gap-1">
|
||||
<LoaderCircle size="12" class="animate-spin" /> Saving...
|
||||
</span>
|
||||
{:else if patch_status === 'success'}
|
||||
<span class="text-success-500 flex items-center gap-1">
|
||||
<Check size="12" /> Saved
|
||||
</span>
|
||||
{:else if patch_status === 'error'}
|
||||
<div class="text-error-500 flex flex-col gap-0.5">
|
||||
<span class="flex items-center gap-1 font-bold">
|
||||
<CircleAlert size="12" /> Error
|
||||
</span>
|
||||
<span class="opacity-80">{error_message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions flex gap-2">
|
||||
{#if allow_null && draft_value !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-error"
|
||||
onclick={() => (draft_value = null)}
|
||||
aria-label="Clear value"
|
||||
title="Clear value">
|
||||
<Eraser size="14" class="mr-1" />
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-primary-500"
|
||||
onclick={handle_patch}
|
||||
aria-label="Save changes"
|
||||
title="Save changes"
|
||||
disabled={patch_status === 'processing' || draft_value === to_input_value(current_value, field_type)}>
|
||||
{#if patch_status === 'processing'}
|
||||
<LoaderCircle size="14" class="mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Save size="14" class="mr-1" />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="ae_field_editor group relative {class_li}"
|
||||
class:block={display_block}
|
||||
class:inline-block={!display_block}
|
||||
role="none"
|
||||
onkeydown={handle_keydown}>
|
||||
|
||||
<!-- VIEW MODE -->
|
||||
<div class="view_wrapper flex items-center gap-2" class:hidden={is_editing}>
|
||||
|
||||
<!-- VIEW MODE: stays visible in modal mode so layout doesn't shift -->
|
||||
<div class="view_wrapper flex items-center gap-2" class:hidden={is_editing && !display_modal}>
|
||||
<div class="content_render grow">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
@@ -281,6 +487,7 @@ function handle_keydown(e: KeyboardEvent) {
|
||||
</div>
|
||||
|
||||
<button
|
||||
bind:this={trigger_ref}
|
||||
type="button"
|
||||
class="btn-icon btn-icon-sm preset-tonal-warning opacity-20 transition-opacity hover:opacity-100"
|
||||
class:invisible={!$ae_loc.edit_mode}
|
||||
@@ -293,162 +500,32 @@ function handle_keydown(e: KeyboardEvent) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- EDIT MODE -->
|
||||
{#if is_editing}
|
||||
<!-- EDIT MODE — inline or absolute (not used in modal mode) -->
|
||||
{#if is_editing && !display_modal}
|
||||
<div
|
||||
class="edit_wrapper border-warning-500/50 bg-surface-50-950 z-50 rounded-lg border-2 border-dashed p-3 shadow-xl"
|
||||
class:absolute={display_absolute_edit}
|
||||
class:top-0={display_absolute_edit}
|
||||
class:left-0={display_absolute_edit}
|
||||
class:w-full={display_absolute_edit}>
|
||||
|
||||
<header class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-bold tracking-wider uppercase opacity-60">
|
||||
{edit_label || field_name}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon btn-icon-sm preset-tonal-surface"
|
||||
onclick={cancel_edit}
|
||||
aria-label="Cancel editing"
|
||||
title="Cancel editing"
|
||||
disabled={patch_status === 'processing'}>
|
||||
<X size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="input_container mb-3">
|
||||
{#if field_type === 'textarea'}
|
||||
<textarea
|
||||
{id}
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
rows={textarea_rows}
|
||||
class="textarea"
|
||||
{placeholder}></textarea>
|
||||
{:else if field_type === 'select'}
|
||||
<div class="relative">
|
||||
<select
|
||||
{id}
|
||||
bind:this={input_ref}
|
||||
value={draft_value}
|
||||
onchange={(e) => draft_value = coerce_select_value(e.currentTarget.value, current_value)}
|
||||
class="select pr-10">
|
||||
{#if allow_null}
|
||||
<option value={null}>-- None --</option>
|
||||
{/if}
|
||||
{#if Object.keys(select_options).length === 0}
|
||||
<option value="" disabled>Loading options...</option>
|
||||
{/if}
|
||||
{#each Object.entries(select_options) as [val, label] (val)}
|
||||
<option value={val}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if Object.keys(select_options).length === 0}
|
||||
<div class="absolute inset-y-0 right-8 flex items-center">
|
||||
<LoaderCircle size="14" class="animate-spin opacity-50" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if field_type === 'checkbox'}
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
{id}
|
||||
type="checkbox"
|
||||
bind:this={input_ref}
|
||||
bind:checked={draft_value}
|
||||
class="checkbox" />
|
||||
<span>{draft_value ? 'True' : 'False'}</span>
|
||||
</label>
|
||||
{:else if field_type === 'tiptap'}
|
||||
<AE_Comp_Editor_TipTap
|
||||
bind:content={draft_value}
|
||||
{placeholder} />
|
||||
{:else if field_type === 'codemirror'}
|
||||
<AE_Comp_Editor_CodeMirror
|
||||
bind:content={draft_value}
|
||||
{placeholder} />
|
||||
{:else if field_type === 'date'}
|
||||
<input
|
||||
{id}
|
||||
type="date"
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
class="input" />
|
||||
{:else if field_type === 'datetime'}
|
||||
<input
|
||||
{id}
|
||||
type="datetime-local"
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
class="input" />
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
type={field_type === 'number' ? 'number' : field_type === 'email' ? 'email' : field_type === 'url' ? 'url' : field_type === 'tel' ? 'tel' : 'text'}
|
||||
bind:this={input_ref}
|
||||
bind:value={draft_value}
|
||||
class="input"
|
||||
{placeholder}
|
||||
onkeydown={(e) => e.key === 'Enter' && handle_patch()} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<footer class="flex items-center justify-between">
|
||||
<div class="status_indicator text-xs">
|
||||
{#if patch_status === 'processing'}
|
||||
<span class="text-primary-500 flex items-center gap-1">
|
||||
<LoaderCircle size="12" class="animate-spin" /> Saving...
|
||||
</span>
|
||||
{:else if patch_status === 'success'}
|
||||
<span class="text-success-500 flex items-center gap-1">
|
||||
<Check size="12" /> Saved
|
||||
</span>
|
||||
{:else if patch_status === 'error'}
|
||||
<div class="text-error-500 flex flex-col gap-0.5">
|
||||
<span class="flex items-center gap-1 font-bold">
|
||||
<CircleAlert size="12" /> Error
|
||||
</span>
|
||||
<span class="opacity-80">{error_message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions flex gap-2">
|
||||
{#if allow_null && draft_value !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-tonal-error"
|
||||
onclick={() => (draft_value = null)}
|
||||
aria-label="Clear value"
|
||||
title="Clear value">
|
||||
<Eraser size="14" class="mr-1" />
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-primary-500"
|
||||
onclick={handle_patch}
|
||||
aria-label="Save changes"
|
||||
title="Save changes"
|
||||
disabled={patch_status === 'processing' || draft_value === to_input_value(current_value, field_type)}>
|
||||
{#if patch_status === 'processing'}
|
||||
<LoaderCircle size="14" class="mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Save size="14" class="mr-1" />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
{@render edit_panel()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- MODAL MODE — <dialog> renders in the browser top layer, never clipped by table/overflow -->
|
||||
{#if display_modal}
|
||||
<dialog
|
||||
bind:this={dialog_ref}
|
||||
style={dialog_style}
|
||||
class="ae_field_editor_dialog border-surface-200-800 w-full max-w-sm rounded-xl border p-4 shadow-2xl"
|
||||
oncancel={(e) => { e.preventDefault(); cancel_edit(); }}
|
||||
onclick={(e) => { if (e.target === dialog_ref) cancel_edit(); }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') cancel_edit(); }}>
|
||||
{@render edit_panel()}
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.ae_field_editor :global(.btn-icon-sm) {
|
||||
width: 24px;
|
||||
@@ -458,4 +535,23 @@ function handle_keydown(e: KeyboardEvent) {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Solid card-face background using theme variables directly —
|
||||
Skeleton tonal classes are semi-transparent by design, unusable on dialogs.
|
||||
position:fixed + z-index required for non-modal show() which doesn't get
|
||||
the browser top-layer; redundant but harmless for showModal(). */
|
||||
:global(.ae_field_editor_dialog) {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background-color: var(--color-surface-50);
|
||||
color: var(--color-surface-950);
|
||||
}
|
||||
:global(.dark .ae_field_editor_dialog) {
|
||||
background-color: var(--color-surface-900);
|
||||
color: var(--color-surface-50);
|
||||
}
|
||||
:global(.ae_field_editor_dialog::backdrop) {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -238,10 +238,10 @@ function content_preview(ds: ae_DataStore): string {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto space-y-6 p-4">
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<header class="bg-surface-50-900-token border-surface-500/10 flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 shadow-lg">
|
||||
<header class="bg-surface-100-900 border-surface-500/10 flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 shadow-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary-500/10 rounded-lg p-2">
|
||||
<Database size={24} class="text-primary-500" />
|
||||
@@ -535,14 +535,17 @@ function content_preview(ds: ae_DataStore): string {
|
||||
<tbody>
|
||||
{#each results as ds (ds.id ?? ds.data_store_id)}
|
||||
{@const ds_id = ds.id ?? ds.data_store_id}
|
||||
<tr class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors">
|
||||
<tr class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200">
|
||||
<td class="px-3 py-2">
|
||||
<AE_Field_Editor
|
||||
object_type="data_store"
|
||||
object_id={ds_id}
|
||||
field_name="code"
|
||||
edit_label="Code"
|
||||
current_value={ds.code ?? ''}
|
||||
placeholder="store_code"
|
||||
display_modal={true}
|
||||
modal_blocking={false}
|
||||
on_success={() => do_search(false)}>
|
||||
<span class="font-mono" class:opacity-40={!ds.enable}>{ds.code}</span>
|
||||
{#if !ds.enable}<span class="badge preset-tonal-error ml-1 text-[9px]">off</span>{/if}
|
||||
@@ -554,8 +557,10 @@ function content_preview(ds: ae_DataStore): string {
|
||||
object_type="data_store"
|
||||
object_id={ds_id}
|
||||
field_name="name"
|
||||
edit_label="Name"
|
||||
current_value={ds.name ?? ''}
|
||||
placeholder="Display name"
|
||||
display_modal={true}
|
||||
on_success={() => do_search(false)} />
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@@ -563,9 +568,11 @@ function content_preview(ds: ae_DataStore): string {
|
||||
object_type="data_store"
|
||||
object_id={ds_id}
|
||||
field_name="type"
|
||||
edit_label="Type"
|
||||
current_value={ds.type ?? 'text'}
|
||||
field_type="select"
|
||||
select_options={ds_type_options}
|
||||
display_modal={true}
|
||||
on_success={() => do_search(false)}>
|
||||
<span class="badge {type_badge(ds.type)} font-mono text-[9px] uppercase">{ds.type ?? '?'}</span>
|
||||
</AE_Field_Editor>
|
||||
|
||||
Reference in New Issue
Block a user