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:
Scott Idem
2026-06-17 17:21:17 -04:00
parent 313eca076d
commit 04205e4a63
2 changed files with 255 additions and 152 deletions

View File

@@ -61,6 +61,9 @@ interface Props {
edit_label?: string; edit_label?: string;
display_block?: boolean; display_block?: boolean;
display_absolute_edit?: boolean; display_absolute_edit?: boolean;
display_modal?: boolean;
modal_blocking?: boolean;
modal_placement?: 'center' | 'above' | 'below' | 'left' | 'right';
placeholder?: string; placeholder?: string;
class_li?: string; class_li?: string;
textarea_rows?: number; textarea_rows?: number;
@@ -92,6 +95,9 @@ let {
edit_label = 'Edit Field', edit_label = 'Edit Field',
display_block = false, display_block = false,
display_absolute_edit = false, display_absolute_edit = false,
display_modal = false,
modal_blocking = true,
modal_placement = 'center' as 'center' | 'above' | 'below' | 'left' | 'right',
placeholder = 'Enter value...', placeholder = 'Enter value...',
class_li = '', class_li = '',
textarea_rows = 4, textarea_rows = 4,
@@ -159,6 +165,9 @@ let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
let error_message = $state(''); let error_message = $state('');
let draft_value = $state(to_input_value(current_value, field_type)); let draft_value = $state(to_input_value(current_value, field_type));
let input_ref = $state<HTMLElement | null>(null); 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 // Optimistic display state machine
let has_optimistic = $state(false); 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() { async function handle_patch() {
if (log_lvl) console.log(`AE Field Editor (new): Patching ${object_type}.${field_name}...`); 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() { 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; has_optimistic = false;
draft_value = to_input_value(current_value, field_type); draft_value = to_input_value(current_value, field_type);
is_editing = false; is_editing = false;
@@ -237,11 +273,36 @@ function cancel_edit() {
error_message = ''; 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() { function toggle_edit() {
if (is_editing) cancel_edit(); if (is_editing) cancel_edit();
else { else {
has_optimistic = false; has_optimistic = false;
draft_value = to_input_value(current_value, field_type); draft_value = to_input_value(current_value, field_type);
if (display_modal) calc_dialog_pos();
is_editing = true; is_editing = true;
if (on_open) on_open(); if (on_open) on_open();
} }
@@ -252,15 +313,160 @@ function handle_keydown(e: KeyboardEvent) {
} }
</script> </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 <div
class="ae_field_editor group relative {class_li}" class="ae_field_editor group relative {class_li}"
class:block={display_block} class:block={display_block}
class:inline-block={!display_block} class:inline-block={!display_block}
role="none" role="none"
onkeydown={handle_keydown}> onkeydown={handle_keydown}>
<!-- VIEW MODE --> <!-- 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}> <div class="view_wrapper flex items-center gap-2" class:hidden={is_editing && !display_modal}>
<div class="content_render grow"> <div class="content_render grow">
{#if children} {#if children}
{@render children()} {@render children()}
@@ -281,6 +487,7 @@ function handle_keydown(e: KeyboardEvent) {
</div> </div>
<button <button
bind:this={trigger_ref}
type="button" type="button"
class="btn-icon btn-icon-sm preset-tonal-warning opacity-20 transition-opacity hover:opacity-100" class="btn-icon btn-icon-sm preset-tonal-warning opacity-20 transition-opacity hover:opacity-100"
class:invisible={!$ae_loc.edit_mode} class:invisible={!$ae_loc.edit_mode}
@@ -293,162 +500,32 @@ function handle_keydown(e: KeyboardEvent) {
</button> </button>
</div> </div>
<!-- EDIT MODE --> <!-- EDIT MODE — inline or absolute (not used in modal mode) -->
{#if is_editing} {#if is_editing && !display_modal}
<div <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="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:absolute={display_absolute_edit}
class:top-0={display_absolute_edit} class:top-0={display_absolute_edit}
class:left-0={display_absolute_edit} class:left-0={display_absolute_edit}
class:w-full={display_absolute_edit}> class:w-full={display_absolute_edit}>
{@render 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>
</div> </div>
{/if} {/if}
</div> </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> <style>
.ae_field_editor :global(.btn-icon-sm) { .ae_field_editor :global(.btn-icon-sm) {
width: 24px; width: 24px;
@@ -458,4 +535,23 @@ function handle_keydown(e: KeyboardEvent) {
justify-content: center; justify-content: center;
padding: 0; 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> </style>

View File

@@ -238,10 +238,10 @@ function content_preview(ds: ae_DataStore): string {
} }
</script> </script>
<div class="container mx-auto space-y-6 p-4"> <div class="space-y-6">
<!-- ── Header ──────────────────────────────────────────────────────────── --> <!-- ── 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="flex items-center gap-3">
<div class="bg-primary-500/10 rounded-lg p-2"> <div class="bg-primary-500/10 rounded-lg p-2">
<Database size={24} class="text-primary-500" /> <Database size={24} class="text-primary-500" />
@@ -535,14 +535,17 @@ function content_preview(ds: ae_DataStore): string {
<tbody> <tbody>
{#each results as ds (ds.id ?? ds.data_store_id)} {#each results as ds (ds.id ?? ds.data_store_id)}
{@const ds_id = 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"> <td class="px-3 py-2">
<AE_Field_Editor <AE_Field_Editor
object_type="data_store" object_type="data_store"
object_id={ds_id} object_id={ds_id}
field_name="code" field_name="code"
edit_label="Code"
current_value={ds.code ?? ''} current_value={ds.code ?? ''}
placeholder="store_code" placeholder="store_code"
display_modal={true}
modal_blocking={false}
on_success={() => do_search(false)}> on_success={() => do_search(false)}>
<span class="font-mono" class:opacity-40={!ds.enable}>{ds.code}</span> <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} {#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_type="data_store"
object_id={ds_id} object_id={ds_id}
field_name="name" field_name="name"
edit_label="Name"
current_value={ds.name ?? ''} current_value={ds.name ?? ''}
placeholder="Display name" placeholder="Display name"
display_modal={true}
on_success={() => do_search(false)} /> on_success={() => do_search(false)} />
</td> </td>
<td class="px-3 py-2"> <td class="px-3 py-2">
@@ -563,9 +568,11 @@ function content_preview(ds: ae_DataStore): string {
object_type="data_store" object_type="data_store"
object_id={ds_id} object_id={ds_id}
field_name="type" field_name="type"
edit_label="Type"
current_value={ds.type ?? 'text'} current_value={ds.type ?? 'text'}
field_type="select" field_type="select"
select_options={ds_type_options} select_options={ds_type_options}
display_modal={true}
on_success={() => do_search(false)}> on_success={() => do_search(false)}>
<span class="badge {type_badge(ds.type)} font-mono text-[9px] uppercase">{ds.type ?? '?'}</span> <span class="badge {type_badge(ds.type)} font-mono text-[9px] uppercase">{ds.type ?? '?'}</span>
</AE_Field_Editor> </AE_Field_Editor>