feat: scaffold element_ae_obj_field_editor_new — parallel-run rewrite of inline field editor

Creates the _new version of the field editor element alongside the working
original (which remains untouched). The _new file starts from the original's
hardened optimistic-update state machine and adds:

- Svelte 5 generics (T) on current_value/draft_value instead of any
- email / url / tel added to the field_type union with edit-mode branches
- object_reload prop removed (was declared, never implemented — on_success
  is and remains the caller's cache-refresh hook)
- to_input_value() / from_input_value() stubs at the two right call sites
  for datetime conversion (both directions, TODO #4 to implement)
- coerce_select_value() stub for select type-mismatch fix (TODO #5)
- Inline TODO mini how-to checked-list mirroring the project doc
- Styling still uses Skeleton classes — tagged for Tailwind/Flowbite swap
  (TODO #6) and a11y gaps marked at their exact markup locations (TODO #7)

Companion files:
- documentation/PROJECT__AE_Obj_Field_Editor_New.md — full plan, migration
  order for all 8 call sites, naming convention note
- documentation/TODO__Agents.md — new entry pointing to the project doc
- documentation/README__Docs_Index.md — project doc added to Active Projects

npx svelte-check: 0 errors, 1 expected benign warning (state_referenced_locally
on the field_type initializer, documented in-file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-16 16:47:36 -04:00
parent 49e3fb18b2
commit 528fb9b33f
4 changed files with 743 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
# Project: AE Obj Field Editor — `_new` Rewrite
**Status:** 🟡 Planning
**Priority:** Medium — quality-of-life primitive, no event deadline attached
**Created:** 2026-06-16
**Last Updated:** 2026-06-16
**Related:** `BOOTSTRAP__AI_Agent_Quickstart.md`, `AE__Naming_Conventions.md`
---
## Background
`src/lib/elements/element_ae_obj_field_editor.svelte` is the generic inline
field editor: given `object_type` + `object_id` + `field_name` + `current_value`,
it renders a "click pencil → edit one field → Save" UI and PATCHes just that
field via `api.update_ae_obj()`. It's used in 8 call sites today (session_view,
presenter_view, location_view, person_view, device list, presentation list,
location list, leads manage tab).
This component already went through one rename cycle — it was originally built
as `element_ae_obj_field_editor_v3.svelte` (replacing an older CRUD v1/v2
pattern), then renamed to drop the `_v3` suffix once it became canonical. That
migration (build new → migrate callers one at a time → delete old → drop
suffix) worked well and is the template for this project.
**Review findings (2026-06-16)** — see `REFERENCE__Common_Agent_Mistakes.md`
context and the chat log for full detail:
1. **Built entirely on Skeleton UI classes** (`btn-icon`, `variant-soft-*`,
`variant-filled-*`, `.input`/`.select`/`.textarea`/`.checkbox`, `badge`).
Conflicts with the stated Tailwind v4 + Flowbite/ShadCN direction. This is
the highest-leverage place to do that swap — fix once in `elements/`,
every call site benefits.
2. **`object_reload` prop is dead.** Declared, defaults to `true`, commented
"SWR pattern" — never read in the script. Every call site compensates by
hand-rolling identical `on_success={() => events_func.load_ae_obj_id__*(...)}`
boilerplate. Either implement it for real or remove it and document
`on_success` as the actual refresh mechanism.
3. **Datetime fields need manual format conversion at every call site, only
on the way in, never on the way out.** Confirmed in `session_view.svelte`:
callers must pre-convert with `to_datetime_local(...)` before passing
`current_value` because the component does no normalization between the
stored datetime string and `<input type="datetime-local">`'s expected
format. On save, the native datetime-local string goes straight to the
PATCH body with no reverse conversion. Works today because the backend is
forgiving, but the contract is leaky.
4. **Select-binding type mismatch is a latent landmine.** `Object.entries()`
always yields string `val`s for `<option value>`. Today's usages
(`event_location_id`, `poc_person_id`) are string IDs already, so it's
invisible — but a true numeric/boolean enum field would break the
optimistic-clear check (`current_value === draft_value`, strict equality),
leaving the field stuck on the optimistic value.
5. **Missing field types**: no `email` (no native keyboard/validation), no
`url`/`tel`. Original design intent explicitly wanted email support.
6. **`current_value`/`draft_value` typed `any`** — no compile-time safety.
Svelte 5 supports generics (`<script generics="T">`); this primitive
should use them.
7. **Minor a11y/polish gaps**: invisible edit-trigger button stays
tab-focusable when `$ae_loc.edit_mode` is off; icon-only Save/Cancel
buttons lack `aria-label`; error message is tooltip-only (`title` attr,
not visible/announced); no Escape-to-cancel; no autofocus on entering
edit mode.
8. **Coarse-store cost (not a bug, just a known tradeoff)**: reads
`$ae_loc.edit_mode` directly in the template. Fine in isolation, but this
component is instantiated per-row in list tables, so any unrelated
`$ae_loc` write re-renders every instance on the page. Not chasing this
now — noting it in case a list ever feels janky.
What's *not* broken and should be preserved as-is: the optimistic-display
state machine (`has_optimistic` / `draft_value` / clearing once `current_value`
catches up) is already hardened — it went through a real bug-fix round
(layout shift, bindable crash, optimistic display) and works correctly for
every field type in production use today.
---
## Goals
1. **Near drop-in replacement.** Same prop contract (names, types, defaults)
wherever possible, so migrating a call site is an import swap plus maybe
one or two new optional props — not a rewrite of the call site.
2. **Don't let "drop-in" block real fixes.** Where the old contract is part
of the problem (e.g. `object_reload` doing nothing, datetime conversion
being the caller's job), change it — call sites get updated during
migration anyway, so this is the moment to fix it properly rather than
carry the leak forward.
3. **Run both versions in parallel during migration.** The old file stays in
place and fully working until every call site has migrated and been
verified. No "migrate and pray" — verify each call site, then move to the
next.
4. **One canonical "_new" rewrite.** Per project naming convention going
forward: when rewriting an existing thing, the in-progress replacement is
named `_new` (not `_v2`/`_v4`/etc.) regardless of how many older
versioned files may have existed historically for other components. There
should only ever be one "new version of whatever we're working on" at a
time. Once migration is complete, drop the `_new` suffix and delete the
old file — exactly as was done for the previous `_v3` → (no suffix) rename.
---
## Non-Goals
- Multi-field / batch editing in one widget — out of scope, this stays a
single-field editor by design.
- JSON sub-field editing (`cfg_json.some_key`) — not a supported field_type
today, not adding it here either.
- Changing the underlying `api.update_ae_obj()` PATCH semantics.
---
## New Component: `element_ae_obj_field_editor_new.svelte`
### Prop contract changes from the original
| Prop | Change | Why |
|---|---|---|
| `object_reload` | **Removed.** | Never implemented; `on_success` is and remains the real refresh hook. Removing rather than silently leaving a dead, misleading prop. |
| `field_type` | **Add** `'email'` \| `'url'` \| `'tel'`. | Explicitly wanted in the original design, never added. |
| `current_value` / `draft_value` | **Typed via generic** (`<script generics="T">`) instead of `any`. | Compile-time safety for callers; this is meant to be used consistently project-wide. |
| Datetime conversion | **Owned by the component**, both directions (stored format ↔ `datetime-local`/`date` input format). | Removes the `to_datetime_local(...)` boilerplate currently required at every datetime call site, and fixes the missing reverse conversion on save. |
| Select value coercion | **Coerce `draft_value` back to `typeof current_value`** after a select change (or compare via `String()` in the optimistic-clear check). | Prevents the stuck-optimistic-state landmine for any future numeric/boolean enum field. |
| Everything else (`object_type`, `object_id`, `field_name`, `allow_null`, `select_options`, `edit_label`, `display_block`, `display_absolute_edit`, `placeholder`, `class_li`, `textarea_rows`, `log_lvl`, `on_success`, `on_error`, `children`) | **Unchanged.** | Drop-in for every call site that doesn't use `object_reload`. |
### Styling
Rebuilt on Tailwind v4 utility classes + Flowbite, matching whatever the
current non-Skeleton convention is elsewhere in the app (check a recently
touched component, e.g. the Pres Mgmt Config page, for the current baseline
look). No Skeleton classes (`btn-icon`, `variant-*`, `.input`/`.select`/etc.)
in the new file.
### Polish fixed opportunistically while rewriting
- `aria-label` on icon-only Save/Cancel/edit-trigger buttons.
- `tabindex="-1"` on the edit-trigger button when invisible (`$ae_loc.edit_mode`
off), so it's not in the tab order while non-interactive.
- Visible inline error text (not just a `title` tooltip) when `patch_status === 'error'`.
- Escape key cancels edit mode (mirrors existing Enter-to-save on text/number).
- Autofocus the input when entering edit mode.
---
## Migration Plan
1. **Build `element_ae_obj_field_editor_new.svelte`** in `src/lib/elements/`,
alongside the existing file. Keep the old file completely untouched and
working during this phase.
2. **Smoke-test in isolation** — add it to `src/routes/testing/ae_obj_field_editor/+page.svelte`
(the existing test playground for the old component) alongside the old
one, covering every `field_type` including the new `email`/`url`/`tel`.
3. **Migrate call sites one at a time**, in this order (simplest/lowest-risk
first, datetime-heavy ones last since they exercise the riskiest contract
change):
- `src/routes/core/person_view.svelte`
- `src/routes/events/[event_id]/(pres_mgmt)/device/device/ae_comp__event_device_obj_li.svelte`
- `src/routes/events/[event_id]/(pres_mgmt)/locations/ae_comp__event_location_obj_li.svelte`
- `src/routes/events/[event_id]/(pres_mgmt)/location/[event_location_id]/location_view.svelte`
- `src/routes/events/ae_comp__event_presentation_obj_li.svelte`
- `src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte`
- `src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/presenter_view.svelte`
- `src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/session_view.svelte`
(has the datetime fields — migrate last, drop the `to_datetime_local(...)`
wrapper at the call site once the component owns that conversion)
4. **Per call site:** swap the import, remove any now-unnecessary
`object_reload` usage (there is none today — confirmed dead) and any
manual datetime pre-conversion, run `npx svelte-check`, visually verify
the field in the running app (view mode, edit mode, save, optimistic
display, error path).
5. **Once all 8 call sites + the test playground are migrated and verified**:
delete `element_ae_obj_field_editor.svelte` (move to `~/tmp/agents_trash`,
never `rm`), rename `element_ae_obj_field_editor_new.svelte`
`element_ae_obj_field_editor.svelte`, update the now-stale import paths
left by the rename, run `npx svelte-check` one final time.
6. **Update docs**: `AE__Naming_Conventions.md` if a "_new" rewrite
convention note belongs there, `BOOTSTRAP__AI_Agent_Quickstart.md` /
`REFERENCE__Common_Agent_Mistakes.md` if anything generalizable came out
of the datetime/select-coercion fixes, and mark this doc complete.
Both files coexist for the full duration of steps 14. Nothing is deleted
until every consumer is migrated and verified.
---
## Open Questions
- None blocking the start of step 1. Revisit field-by-field styling choices
(e.g. exact Tailwind/Flowbite input classes) against whatever the most
recently styled form in the app is using, since Skeleton phase-out is
ongoing and conventions may still be settling.
---
## Naming Convention Note (for future rewrites like this)
Going forward, an in-progress full rewrite of an existing component/module
is named `_new` regardless of how many historically-versioned files
(`_v1`/`_v2`/`_v3`/etc.) may exist elsewhere in the codebase for unrelated
components. There should be at most one "new version of the thing we're
currently rewriting" in flight at a time. Once migration completes, the
suffix is dropped and the old file is deleted — there is no permanent `_new`
file left behind, just like there's no permanent `_v3` file left behind from
the previous round of this same component.

View File

@@ -55,6 +55,7 @@ Use this file as the routing map for project documentation.
- `documentation/PROJECT__IDAA_Stores_Svelte5_Migration_2026.md` - `documentation/PROJECT__IDAA_Stores_Svelte5_Migration_2026.md`
- `documentation/PROJECT__AE_Events_PressMgmt_Config_Cleanup.md` - `documentation/PROJECT__AE_Events_PressMgmt_Config_Cleanup.md`
- `documentation/PROJECT__AE_Site_Passcode_Security.md` - `documentation/PROJECT__AE_Site_Passcode_Security.md`
- `documentation/PROJECT__AE_Obj_Field_Editor_New.md`
## 6) Active Proposals ## 6) Active Proposals

View File

@@ -49,6 +49,12 @@ Axonius DC (June 9) is done — the show happened and the badge layout work that
--- ---
## 🚧 AE Obj Field Editor — `_new` Rewrite (planning, 2026-06-16)
`element_ae_obj_field_editor.svelte` is getting a parallel-run rewrite: Skeleton UI → Tailwind/Flowbite, removes the dead `object_reload` prop, fixes datetime format conversion (currently the caller's job, one-directional), fixes a latent select-binding type-coercion landmine, adds `email`/`url`/`tel` field types, adds generics for `current_value`/`draft_value`. Both versions run side by side until all 8 call sites are migrated and verified; see `PROJECT__AE_Obj_Field_Editor_New.md` for the full plan and migration order. Not started yet — no code written.
---
## 🚧 V3 CRUD Migration (Surgical Cleanup) ## 🚧 V3 CRUD Migration (Surgical Cleanup)
Finalizing the 100% adoption of V3 Standard endpoints and retirement of legacy wrappers. Finalizing the 100% adoption of V3 Standard endpoints and retirement of legacy wrappers.

View File

@@ -0,0 +1,534 @@
<!--
ELEMENT: AE Obj Field Editor — NEW VERSION (scaffold, not yet wired up)
========================================================================
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.
MINI HOW-TO — work the TODOs below top to bottom. Each one maps to the
matching numbered item in the project doc's "New Component" section,
which has the full "why". Check them off here as you go so the next
person picking this up can see progress at a glance.
[ ] 1. Generics — `current_value`/`draft_value` are typed `T` already
below via the `generics="T"` script attribute. Confirm nothing
inside the script still needs an `any` cast once you fill in #4/#5.
[ ] 2. field_type — 'email' | 'url' | 'tel' are added to the union below.
Edit-mode branches for them are stubbed in the markup — they're
currently copy-pasted from the 'text' branch with the right
`type=` attribute, which is all they strictly need. Confirm that's
still true once you've picked real input styling (#6).
[ ] 3. object_reload — already removed from Props. If you copy any more
logic over from the original, double check it doesn't reference
object_reload anywhere (it was dead code — on_success is and
remains the real cache-refresh hook for every caller).
[ ] 4. Datetime conversion — THE BIG ONE. The original makes every caller
pre-convert current_value with a `to_datetime_local()` helper
before passing it in, and never converts anything back on save.
This component should own both directions instead. Fill in
`to_input_value()` / `from_input_value()` below — they're called
at the two right spots already (computing draft_value, and right
before the PATCH body is built in handle_patch). Once this works,
the `to_datetime_local(...)` call at each date/datetime call site
goes away during migration (see project doc migration plan).
[ ] 5. Select value coercion — fill in `coerce_select_value()` below and
wire it into the <select> onchange. Today `Object.entries()`
always produces string option values, so a select bound to a
non-string field (number/boolean enum) would get draft_value
stuck out of sync with current_value forever (strict `===` in the
optimistic-clear $effect never resolves). No real-world field hits
this today (current select usages are all string IDs), but fix it
here since it's a one-line landmine waiting for the next one.
[ ] 6. Styling — replace every Skeleton UI class in this file (search for
"btn-icon", "variant-", ".input"/".select"/".textarea"/".checkbox",
"badge") with Tailwind v4 + Flowbite equivalents. Check a recently
styled form (e.g. the Pres Mgmt Config page,
src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/config/+page.svelte)
for the current house style before inventing new classes.
[ ] 7. Accessibility polish:
- aria-label on the icon-only edit-trigger / Save / Cancel buttons
- tabindex="-1" on the edit-trigger button when it's invisible
(mirrors the existing class:invisible logic)
- visible inline error text when patch_status === 'error', not
just the title="" tooltip it has today
- Escape key cancels edit mode (mirrors the existing Enter-to-save
on text/number/email/url/tel)
- autofocus the input element when entering edit mode
When every box above is checked:
1. Add this component to the test playground
(src/routes/testing/ae_obj_field_editor/+page.svelte) alongside the
old one and exercise every field_type, including the 3 new ones.
2. Start the migration plan in the project doc — call sites, in order,
one at a time, verifying with `npx svelte-check` + a visual check
after each one.
3. Once everything is migrated: delete the old file (to
~/tmp/agents_trash, never rm), rename this file to drop "_new", fix
any leftover import paths, run `npx svelte-check` one last time, mark
the project doc complete.
-->
<script lang="ts" generics="T">
// import { browser } from '$app/environment';
import { untrack } from 'svelte';
import type { Snippet } from 'svelte';
import {
Check,
CircleAlert,
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_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
interface Props {
// Core Identifiers
id?: string;
object_type: string;
object_id: string;
field_name: string;
// Value Handling
current_value: T;
field_type?:
| 'text'
| 'textarea'
| 'select'
| 'tiptap'
| 'checkbox'
| 'date'
| 'datetime'
| 'number'
| 'email' // TODO #2 — new
| 'url' // TODO #2 — new
| 'tel'; // TODO #2 — new
allow_null?: boolean;
// Select Options
select_options?: key_val; // { value: label }
// UI Configuration
edit_label?: string;
display_block?: boolean;
display_absolute_edit?: boolean;
placeholder?: string;
class_li?: string;
textarea_rows?: number;
// NOTE: object_reload was intentionally removed (TODO #3) — it was
// declared in the original component but never read anywhere. Every
// caller already does its own cache refresh via on_success; that stays
// the real mechanism here too.
// Behavior
log_lvl?: number;
// Callbacks
on_success?: (data: any) => void;
on_error?: (error: any) => void;
// Snippets
children?: Snippet;
}
let {
id,
object_type,
object_id,
field_name,
current_value = $bindable(),
field_type = 'text',
allow_null = false,
select_options = {},
edit_label = 'Edit Field',
display_block = false,
display_absolute_edit = false,
placeholder = 'Enter value...',
class_li = '',
textarea_rows = 4,
log_lvl = 0,
on_success,
on_error,
children
}: Props = $props();
// ---------------------------------------------------------------------
// TODO #4 — Datetime conversion (and any other future format-mismatch
// field types). Both directions live here so the component owns the
// contract instead of every call site re-implementing it.
// ---------------------------------------------------------------------
// to_input_value: stored value (T, e.g. whatever ae_obj.start_datetime
// looks like coming back from the API) -> what the native <input> wants
// (e.g. "YYYY-MM-DDTHH:mm" for type="datetime-local", "YYYY-MM-DD" for
// type="date"). Called wherever draft_value is set from current_value.
function to_input_value(value: T, type: typeof field_type): any {
// PLACEHOLDER — passes through unchanged. This is the same gap the
// original component has today (callers currently do this conversion
// themselves before passing current_value in — see
// session_view.svelte's to_datetime_local() calls). Implement real
// date/datetime conversion here; leave everything else passed through.
return value;
}
// from_input_value: reverse of the above — native <input> value -> the
// format the backend expects in the PATCH body. Called in handle_patch()
// right before building the `fields` payload.
function from_input_value(value: any, type: typeof field_type): any {
// PLACEHOLDER — see to_input_value() above.
return value;
}
// ---------------------------------------------------------------------
// TODO #5 — Select value coercion. Native <select> values are always
// strings. Coerce back to the real type (based on the shape of
// current_value) so equality checks against current_value still work
// once liveQuery/Dexie returns the saved (typed) value.
// ---------------------------------------------------------------------
function coerce_select_value(raw: string, reference: T): any {
// PLACEHOLDER — naive starting point, refine against real field types
// as they come up:
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('');
// svelte-check warns "state_referenced_locally" here because field_type is
// only captured at mount time — that's intentional (field_type isn't meant
// to change on a live instance; the $effect below re-derives draft_value
// from current_value on every relevant change anyway). Benign, not a TODO.
let draft_value = $state(to_input_value(current_value, field_type));
// 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.
// KEEP — hardened in production (commit c3ec0f88e). Don't modify casually.
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.
// KEEP — same note as above.
$effect(() => {
if (!is_editing) {
untrack(() => {
if (!has_optimistic) {
draft_value = to_input_value(current_value, field_type);
}
});
}
});
// Clear optimistic once liveQuery catches up (current_value matches what we
// saved). NOTE: with TODO #4 done, current_value and draft_value may be in
// different formats (stored vs. input format) for date/datetime — this
// comparison will need to go through from_input_value(draft_value) or
// to_input_value(current_value) to compare like-for-like. Revisit once #4
// is implemented; this is currently a straight copy of the original logic.
$effect(() => {
if (has_optimistic && current_value === draft_value) {
has_optimistic = false;
}
});
async function handle_patch() {
if (log_lvl)
console.log(
`AE Field Editor (new): Patching ${object_type}.${field_name}...`
);
patch_status = 'processing';
error_message = '';
try {
const result = await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: object_type,
obj_id: object_id,
fields: {
// TODO #4 — should be from_input_value(draft_value, field_type)
// once that's implemented, so date/datetime values get
// converted back to the backend's expected format.
[field_name]: draft_value
},
log_lvl
});
if (result) {
patch_status = 'success';
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
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);
} else {
throw new Error('No data returned from update.');
}
} catch (error: any) {
console.error('AE Field Editor (new): Patch failed.', error);
patch_status = 'error';
error_message = error?.message || 'Update failed.';
if (on_error) on_error(error);
}
}
function cancel_edit() {
has_optimistic = false;
draft_value = to_input_value(current_value, field_type);
is_editing = false;
patch_status = 'idle';
error_message = '';
}
function toggle_edit() {
if (is_editing) cancel_edit();
else {
has_optimistic = false; // clear optimistic so draft syncs from current prop
draft_value = to_input_value(current_value, field_type);
is_editing = true;
// TODO #7 — autofocus the input once it's mounted. Easiest path is
// probably a small `use:` action or an `$effect` that calls
// .focus() on a bound element reference once is_editing flips true.
}
}
// TODO #7 — Escape-to-cancel. Add an onkeydown handler (probably on the
// edit_wrapper div, capturing) that calls cancel_edit() on `Escape`,
// alongside the existing per-input Enter-to-save handlers below.
</script>
<!--
TODO #6 — every Skeleton UI class below (btn-icon, variant-*, badge,
.input/.select/.textarea/.checkbox) needs replacing with Tailwind v4 +
Flowbite equivalents. Markup structure/logic otherwise carried forward
from the original as-is.
-->
<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}>
<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'}">
{display_value ? 'True' : 'False'}
</span>
{:else if field_type === 'tiptap'}
<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>
<!-- 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. -->
<!-- TODO #7 — add aria-label, and tabindex="-1" when invisible. -->
<button
type="button"
class="btn-icon btn-icon-sm variant-soft-warning opacity-20 transition-opacity hover:opacity-100"
class:invisible={!$ae_loc.edit_mode}
class:pointer-events-none={!$ae_loc.edit_mode}
onclick={toggle_edit}
title="Edit {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">
<span
class="text-xs font-bold tracking-wider uppercase opacity-60"
>{edit_label || field_name}</span>
<div class="flex gap-1">
<!-- TODO #7 — aria-label="Cancel" -->
<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'}
<!-- TODO #5 — wire coerce_select_value() into an onchange
handler here instead of relying on bind:value alone. -->
<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'}
<!-- TODO #4 — value here should already be in
"YYYY-MM-DD" form via to_input_value(). -->
<input type="date" bind:value={draft_value} class="input" />
{:else if field_type === 'datetime'}
<!-- TODO #4 — value here should already be in
"YYYY-MM-DDTHH:mm" form via to_input_value(). -->
<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 if field_type === 'email'}
<!-- TODO #2 — confirm this needs nothing more than the
type= swap once styling (#6) is done. -->
<input
type="email"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{:else if field_type === 'url'}
<input
type="url"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{:else if field_type === 'tel'}
<input
type="tel"
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'}
<!-- TODO #7 — show error_message visibly here too,
not just via the title="" tooltip below. -->
<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>
</div>
{/if}
</div>
<style>
/* Add any specialized transitions if needed */
.ae_field_editor :global(.btn-icon-sm) {
width: 24px;
height: 24px;
}
</style>