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,56 +313,7 @@ function handle_keydown(e: KeyboardEvent) {
} }
</script> </script>
<div {#snippet edit_panel()}
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}>
<div class="content_render grow">
{#if children}
{@render children()}
{:else if field_type === 'checkbox'}
<span class="badge {display_value ? 'preset-tonal-success' : 'preset-tonal-surface'}">
{display_value ? 'True' : 'False'}
</span>
{:else if field_type === 'tiptap' || field_type === 'codemirror'}
<div class="prose dark:prose-invert max-w-none">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html display_value || '<span class="opacity-50 italic">Empty</span>'}
</div>
{:else}
<span class:opacity-50={!display_value}>
{display_value || 'Not set'}
</span>
{/if}
</div>
<button
type="button"
class="btn-icon btn-icon-sm preset-tonal-warning opacity-20 transition-opacity hover:opacity-100"
class:invisible={!$ae_loc.edit_mode}
class:pointer-events-none={!$ae_loc.edit_mode}
tabindex={$ae_loc.edit_mode ? 0 : -1}
onclick={toggle_edit}
aria-label="Edit {edit_label || field_name}"
title="Edit {edit_label || field_name}">
<SquarePen size="14" />
</button>
</div>
<!-- EDIT MODE -->
{#if is_editing}
<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"> <header class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold tracking-wider uppercase opacity-60"> <span class="text-xs font-bold tracking-wider uppercase opacity-60">
{edit_label || field_name} {edit_label || field_name}
@@ -396,7 +408,6 @@ function handle_keydown(e: KeyboardEvent) {
{/if} {/if}
</div> </div>
<footer class="flex items-center justify-between"> <footer class="flex items-center justify-between">
<div class="status_indicator text-xs"> <div class="status_indicator text-xs">
{#if patch_status === 'processing'} {#if patch_status === 'processing'}
@@ -445,10 +456,76 @@ function handle_keydown(e: KeyboardEvent) {
</button> </button>
</div> </div>
</footer> </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: 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()}
{:else if field_type === 'checkbox'}
<span class="badge {display_value ? 'preset-tonal-success' : 'preset-tonal-surface'}">
{display_value ? 'True' : 'False'}
</span>
{:else if field_type === 'tiptap' || field_type === 'codemirror'}
<div class="prose dark:prose-invert max-w-none">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html display_value || '<span class="opacity-50 italic">Empty</span>'}
</div>
{:else}
<span class:opacity-50={!display_value}>
{display_value || 'Not set'}
</span>
{/if}
</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}
class:pointer-events-none={!$ae_loc.edit_mode}
tabindex={$ae_loc.edit_mode ? 0 : -1}
onclick={toggle_edit}
aria-label="Edit {edit_label || field_name}"
title="Edit {edit_label || field_name}">
<SquarePen size="14" />
</button>
</div>
<!-- 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}>
{@render edit_panel()}
</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>