feat(field-editor): modernize field editor with non-blocking modal and viewport clamping
- Completed rewrite of `element_ae_obj_field_editor.svelte` to Svelte 5 + Tailwind v4 - Set `display_modal = true`, `modal_blocking = false`, and `modal_placement = 'center'` as new defaults - Implemented trigger-relative modal positioning with automatic viewport boundary clamping to prevent off-screen rendering - Migrated all 12 call sites across core and events modules (Session, Presenter, Location, Exhibit, etc.) - Removed legacy datetime-to-local manual conversion logic from views as the component now handles it natively - Retired Skeleton-based legacy component - Updated testing page and documentation to reflect the new standardized primitive
This commit is contained in:
@@ -1,20 +1,34 @@
|
||||
<script lang="ts">
|
||||
// import { browser } from '$app/environment';
|
||||
<!--
|
||||
ELEMENT: AE Obj Field Editor — NEW VERSION
|
||||
========================================================================
|
||||
Full plan / rationale: documentation/PROJECT__AE_Obj_Field_Editor_New.md
|
||||
Original (still the live, working version — do not touch it yet):
|
||||
src/lib/elements/element_ae_obj_field_editor.svelte
|
||||
|
||||
This file starts from the original's logic. The optimistic-update state
|
||||
machine (is_editing / draft_value / has_optimistic / display_value) is
|
||||
already hardened in production (see commit c3ec0f88e) — copy it forward
|
||||
as-is. Do not redesign it unless you find an actual bug; it is NOT part
|
||||
of this rewrite's scope.
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
Check,
|
||||
CircleAlert,
|
||||
Eraser,
|
||||
LoaderCircle,
|
||||
Save,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
import type { key_val } from '$lib/stores/ae_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { api } from '$lib/api/api';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
|
||||
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
|
||||
|
||||
interface Props {
|
||||
// Core Identifiers
|
||||
@@ -24,16 +38,20 @@ interface Props {
|
||||
field_name: string;
|
||||
|
||||
// Value Handling
|
||||
current_value: any;
|
||||
current_value: T;
|
||||
field_type?:
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'select'
|
||||
| 'tiptap'
|
||||
| 'codemirror'
|
||||
| 'checkbox'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'number';
|
||||
| 'number'
|
||||
| 'email'
|
||||
| 'url'
|
||||
| 'tel';
|
||||
allow_null?: boolean;
|
||||
|
||||
// Select Options
|
||||
@@ -43,20 +61,26 @@ 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;
|
||||
|
||||
// Behavior
|
||||
object_reload?: boolean; // SWR pattern
|
||||
log_lvl?: number;
|
||||
|
||||
// Callbacks
|
||||
on_success?: (data: any) => void;
|
||||
on_error?: (error: any) => void;
|
||||
on_open?: () => void | Promise<void>;
|
||||
|
||||
// Snippets
|
||||
children?: Snippet;
|
||||
|
||||
// State
|
||||
is_editing?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -71,84 +95,168 @@ let {
|
||||
edit_label = 'Edit Field',
|
||||
display_block = false,
|
||||
display_absolute_edit = false,
|
||||
display_modal = true,
|
||||
modal_blocking = false,
|
||||
modal_placement = 'center' as 'center' | 'above' | 'below' | 'left' | 'right',
|
||||
placeholder = 'Enter value...',
|
||||
class_li = '',
|
||||
textarea_rows = 4,
|
||||
object_reload = true,
|
||||
log_lvl = 0,
|
||||
on_success,
|
||||
on_error,
|
||||
children
|
||||
on_open,
|
||||
children,
|
||||
is_editing = $bindable(false)
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
* to_input_value: stored value -> what the native <input> wants.
|
||||
*/
|
||||
function to_input_value(value: T, type: typeof field_type): any {
|
||||
if (value === null || value === undefined) return '';
|
||||
|
||||
if (type === 'datetime') {
|
||||
return ae_util.iso_datetime_formatter(value as any, 'datetime_iso_no_seconds').replace(' ', 'T');
|
||||
}
|
||||
|
||||
if (type === 'date') {
|
||||
return ae_util.iso_datetime_formatter(value as any, 'date_iso');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* from_input_value: native <input> value -> format backend expects.
|
||||
*/
|
||||
function from_input_value(value: any, type: typeof field_type): any {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return allow_null ? null : value;
|
||||
}
|
||||
|
||||
if (type === 'datetime') {
|
||||
return value.replace('T', ' ') + ':00';
|
||||
}
|
||||
|
||||
if (type === 'date') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* coerce_select_value: Native <select> values are always strings.
|
||||
* Coerce back to the real type based on the shape of current_value.
|
||||
*/
|
||||
function coerce_select_value(raw: string, reference: T): any {
|
||||
if (raw === 'null' || raw === '') return null;
|
||||
if (typeof reference === 'number') return Number(raw);
|
||||
if (typeof reference === 'boolean') return raw === 'true';
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Internal State
|
||||
let is_editing = $state(false);
|
||||
let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
|
||||
let error_message = $state('');
|
||||
let draft_value = $state(current_value);
|
||||
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('');
|
||||
|
||||
// WHY: Optimistic display. After a successful PATCH, liveQuery may not fire
|
||||
// immediately (or at all if on_success doesn't trigger a Dexie refresh). We show
|
||||
// draft_value as the display until current_value catches up from liveQuery.
|
||||
// Optimistic display state machine
|
||||
let has_optimistic = $state(false);
|
||||
let display_value = $derived(has_optimistic ? draft_value : current_value);
|
||||
|
||||
// Sync draft with display_value when not editing.
|
||||
// Suppress reset if optimistic is active — we already have the right value.
|
||||
// Sync draft with current_value when not editing
|
||||
$effect(() => {
|
||||
if (!is_editing) {
|
||||
untrack(() => {
|
||||
if (!has_optimistic) {
|
||||
draft_value = current_value;
|
||||
draft_value = to_input_value(current_value, field_type);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Clear optimistic once liveQuery catches up (current_value matches what we saved)
|
||||
// Clear optimistic once current_value catches up
|
||||
$effect(() => {
|
||||
if (has_optimistic && current_value === draft_value) {
|
||||
const formatted_current = to_input_value(current_value, field_type);
|
||||
if (has_optimistic && formatted_current === draft_value) {
|
||||
has_optimistic = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Autofocus when entering edit mode (for simple inputs)
|
||||
$effect(() => {
|
||||
if (is_editing && input_ref) {
|
||||
untrack(() => input_ref?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
// 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 V3: Patching ${object_type}.${field_name}...`
|
||||
);
|
||||
if (log_lvl) console.log(`AE Field Editor (new): Patching ${object_type}.${field_name}...`);
|
||||
|
||||
patch_status = 'processing';
|
||||
error_message = '';
|
||||
|
||||
try {
|
||||
const final_value = from_input_value(draft_value, field_type);
|
||||
|
||||
const result = await api.update_ae_obj({
|
||||
api_cfg: $ae_api,
|
||||
obj_type: object_type,
|
||||
obj_id: object_id,
|
||||
fields: {
|
||||
[field_name]: draft_value
|
||||
},
|
||||
fields: { [field_name]: final_value },
|
||||
log_lvl
|
||||
});
|
||||
|
||||
if (result) {
|
||||
patch_status = 'success';
|
||||
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
|
||||
has_optimistic = true;
|
||||
if (on_success) on_success(result);
|
||||
|
||||
// Close edit mode after a brief success indicator
|
||||
setTimeout(() => {
|
||||
if (patch_status === 'success') {
|
||||
patch_status = 'idle';
|
||||
is_editing = false;
|
||||
}
|
||||
}, 1000);
|
||||
}, 800);
|
||||
} else {
|
||||
throw new Error('No data returned from update.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('AE Field Editor V3: Patch failed.', error);
|
||||
console.error('AE Field Editor (new): Patch failed.', error);
|
||||
patch_status = 'error';
|
||||
error_message = error?.message || 'Update failed.';
|
||||
if (on_error) on_error(error);
|
||||
@@ -156,43 +264,249 @@ 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 = current_value;
|
||||
draft_value = to_input_value(current_value, field_type);
|
||||
is_editing = false;
|
||||
patch_status = 'idle';
|
||||
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;
|
||||
|
||||
// Use a fixed max-width for calculations (matching the CSS max-w-sm = 384px)
|
||||
// and an estimated height (200px) to clamp the anchor point safely.
|
||||
const dw = 384;
|
||||
const dh = 240;
|
||||
const padding = 16;
|
||||
|
||||
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 over button
|
||||
top = cy; left = cx; tx = '-50%'; ty = '-50%'; break;
|
||||
}
|
||||
|
||||
// Viewport clamping: adjust the anchor point if the dialog would overflow
|
||||
// This assumes the translate(-50%, -50%) etc. is applied.
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
// Clamp Left/Right (X-axis)
|
||||
if (tx === '-50%') {
|
||||
left = Math.max(dw/2 + padding, Math.min(vw - dw/2 - padding, left));
|
||||
} else if (tx === '-100%') {
|
||||
left = Math.max(dw + padding, left);
|
||||
} else if (tx === '0') {
|
||||
left = Math.min(vw - dw - padding, left);
|
||||
}
|
||||
|
||||
// Clamp Top/Bottom (Y-axis)
|
||||
if (ty === '-50%') {
|
||||
top = Math.max(dh/2 + padding, Math.min(vh - dh/2 - padding, top));
|
||||
} else if (ty === '-100%') {
|
||||
top = Math.max(dh + padding, top);
|
||||
} else if (ty === '0') {
|
||||
top = Math.min(vh - dh - padding, top);
|
||||
}
|
||||
|
||||
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; // clear optimistic so draft syncs from current prop
|
||||
draft_value = current_value;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function handle_keydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancel_edit();
|
||||
}
|
||||
</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}>
|
||||
<!-- VIEW MODE -->
|
||||
<div class="view_wrapper flex items-center gap-2" class:hidden={is_editing}>
|
||||
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
|
||||
? 'variant-filled-success'
|
||||
: 'variant-soft-surface'}">
|
||||
<span class="badge {display_value ? 'preset-tonal-success' : 'preset-tonal-surface'}">
|
||||
{display_value ? 'True' : 'False'}
|
||||
</span>
|
||||
{:else if field_type === 'tiptap'}
|
||||
{:else if field_type === 'tiptap' || field_type === 'codemirror'}
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
{@html display_value ||
|
||||
'<span class="opacity-50 italic">Empty</span>'}
|
||||
<!-- 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}>
|
||||
@@ -201,148 +515,72 @@ function toggle_edit() {
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edit Trigger (Visible on Hover or Edit Mode) -->
|
||||
<!-- WHY: Always render to avoid layout shift when edit_mode toggles.
|
||||
Use invisible (visibility:hidden, preserves space) when edit_mode is off. -->
|
||||
<button
|
||||
bind:this={trigger_ref}
|
||||
type="button"
|
||||
class="btn-icon btn-icon-sm variant-soft-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:pointer-events-none={!$ae_loc.edit_mode}
|
||||
tabindex={$ae_loc.edit_mode ? 0 : -1}
|
||||
onclick={toggle_edit}
|
||||
title="Edit {field_name}">
|
||||
aria-label="Edit {edit_label || field_name}"
|
||||
title="Edit {edit_label || field_name}">
|
||||
<SquarePen size="14" />
|
||||
</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 variant-soft-surface"
|
||||
onclick={cancel_edit}
|
||||
disabled={patch_status === 'processing'}>
|
||||
<X size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="input_container mb-3">
|
||||
{#if field_type === 'textarea'}
|
||||
<textarea
|
||||
bind:value={draft_value}
|
||||
rows={textarea_rows}
|
||||
class="textarea"
|
||||
{placeholder}></textarea>
|
||||
{:else if field_type === 'select'}
|
||||
<select bind:value={draft_value} class="select">
|
||||
{#if allow_null}
|
||||
<option value={null}>-- None --</option>
|
||||
{/if}
|
||||
{#each Object.entries(select_options) as [val, label] (val)}
|
||||
<option value={val}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if field_type === 'checkbox'}
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
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 === 'date'}
|
||||
<input type="date" bind:value={draft_value} class="input" />
|
||||
{:else if field_type === 'datetime'}
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={draft_value}
|
||||
class="input" />
|
||||
{:else if field_type === 'number'}
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft_value}
|
||||
class="input"
|
||||
{placeholder}
|
||||
onkeydown={(e) =>
|
||||
e.key === 'Enter' && handle_patch()} />
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
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'}
|
||||
<span
|
||||
class="text-error-500 flex items-center gap-1"
|
||||
title={error_message}>
|
||||
<CircleAlert size="12" /> Error
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions flex gap-2">
|
||||
{#if allow_null && draft_value !== null}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-soft-error"
|
||||
onclick={() => (draft_value = null)}>
|
||||
Set Null
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-filled-primary"
|
||||
onclick={handle_patch}
|
||||
disabled={patch_status === 'processing' ||
|
||||
draft_value === display_value}>
|
||||
{#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>
|
||||
/* Add any specialized transitions if needed */
|
||||
.ae_field_editor :global(.btn-icon-sm) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user