# Day 9: Accessible Modals, Drawers, Remote Forms, and Inline Editing

**Onboarding track:** Alpine.js + htmx, co-equal architecture  
**Verified baseline:** Alpine.js 3.15.12 and htmx 2.0.10 (stable)  
**Verification date:** 2026-07-19  
**Compatibility boundary:** htmx 4.0.0-beta5 is a prerelease line and is not the syntax baseline for these lessons.

## Purpose

Combine stable local overlay behavior with server-loaded content, repeated validation swaps, deliberate focus, and disposable inline editors.

## How to use this document

Budget a working day. Read the lessons in order, type the examples instead of copying them blindly, perform every mini-task, and finish the exit exercise without looking at the answer notes. The examples use a fictional server-rendered operations application, but the ownership rules are framework-neutral.

A successful day ends with three artifacts:

1. working markup or a small runnable page;
2. written ownership notes for each state value and DOM region;
3. answers to the comprehension check.

## Learning outcomes

You will be able to:

- keep modal or drawer state stable while htmx replaces inner server content;
- use a native `<dialog>` or a tested overlay implementation;
- load remote forms, preserve validation loops, and close only on authoritative success;
- restore focus;
- design inline editors whose local state resets correctly;
- distinguish local overlay mechanics from server workflow state.

## 1. Overlay ownership

A remote modal has multiple owners:

| Concern | Owner |
| --- | --- |
| open/closed state | Alpine or native dialog state coordinated by Alpine |
| focus entry and restoration | Alpine/native dialog/integration policy |
| form HTML | server template |
| form submission and replacement | htmx |
| validation and authorization | server |
| close-on-success fact | server response event |
| backdrop and transition | CSS/Alpine, one animation owner |
| durable record | server/database |

The modal shell normally remains outside the htmx target.

## 2. Why use `<dialog>` as the baseline

A native `<dialog>` provides useful platform behavior for modal presentation, including a modal top layer and browser-managed focus behavior. It still requires:

- a label or heading;
- a close control;
- focus restoration policy;
- Escape/cancel handling;
- testing across supported browsers and assistive technologies;
- a polyfill or alternate implementation if project support requires it.

Do not set `document.body.inert = true` from a simplistic Alpine effect when the dialog itself is inside the body. That can disable the interface you are trying to use.

## 3. Stable remote dialog shell

```html
<section
    x-data="remoteDialog"
    @record-saved.window="closeAfterSuccess"
    @modal-content-loaded.window="focusLoadedContent"
>
    <button
        type="button"
        x-ref="opener"
        hx-get="/records/42/edit"
        hx-target="#record-dialog-body"
        hx-swap="innerHTML"
        hx-indicator="#record-dialog-loading"
        @click="open"
    >
        Edit record
    </button>

    <dialog
        x-ref="dialog"
        @cancel="handleCancel"
        @close="afterClose"
        aria-labelledby="record-dialog-title"
    >
        <header>
            <h2 id="record-dialog-title">Edit record</h2>
            <button type="button" @click="close">Close</button>
        </header>

        <p
            id="record-dialog-loading"
            class="htmx-indicator"
            role="status"
        >
            Loading editor…
        </p>

        <div id="record-dialog-body">
            <!-- htmx-owned server content -->
        </div>
    </dialog>
</section>
```

Registered Alpine component:

```html
<script>
document.addEventListener('alpine:init', () => {
    Alpine.data('remoteDialog', () => ({
        lastOpener: null,

        open() {
            this.lastOpener = this.$refs.opener
            if (!this.$refs.dialog.open) {
                this.$refs.dialog.showModal()
            }
        },

        close() {
            if (this.$refs.dialog.open) {
                this.$refs.dialog.close()
            }
        },

        handleCancel(event) {
            event.preventDefault()
            this.close()
        },

        afterClose() {
            this.$nextTick(() => this.lastOpener?.focus())
        },

        closeAfterSuccess() {
            this.close()
        },

        focusLoadedContent() {
            this.$nextTick(() => {
                this.$refs.dialog
                    .querySelector('[data-dialog-initial-focus]')
                    ?.focus()
            })
        }
    }))
})
</script>
```

Notes:

- the shell and Alpine root survive;
- only `#record-dialog-body` is replaced;
- opening immediately can show a loading state;
- validation failure leaves the dialog open;
- the server emits `record-saved` only on authoritative success;
- the server may emit `modal-content-loaded` after swap when initial focus should move.

For multiple opener buttons, store the actual `$el` in the click handler or dispatch an open command with the source. Do not hardcode one opener reference in a reusable component.

## 4. Remote form contract

The load response:

```html
<form
    id="record-form"
    action="/records/42"
    method="post"
    hx-post="/records/42"
    hx-target="#record-form"
    hx-swap="outerHTML"
>
    <label>
        Title
        <input
            name="title"
            value="Quarterly review"
            data-dialog-initial-focus
        >
    </label>

    <button type="submit">Save</button>
</form>
```

Validation response returns the same root and no success fact:

```html
<form
    id="record-form"
    action="/records/42"
    method="post"
    hx-post="/records/42"
    hx-target="#record-form"
    hx-swap="outerHTML"
>
    <div role="alert" tabindex="-1" data-dialog-initial-focus>
        Correct the highlighted field.
    </div>

    <label>
        Title
        <input
            name="title"
            value=""
            aria-invalid="true"
            aria-describedby="title-error"
        >
    </label>
    <p id="title-error">Title is required.</p>

    <button type="submit">Save</button>
</form>
```

Success may return a confirmation fragment or updated record projection and headers:

```http
HX-Trigger-After-Swap: {"record-saved":{"id":42}}
```

If the row outside the dialog also changes, return it as an OOB swap or target the authoritative row through a separate response design.

## 5. Do not close on request completion

An htmx request can complete with:

- validation failure;
- authorization failure;
- conflict;
- unexpected error;
- success.

Therefore a generic `htmx:afterRequest` listener must not close the dialog. Close on an explicit success fact.

## 6. Drawer pattern

A drawer is often a stable Alpine shell with an htmx interior.

```html
<aside
    x-data="{ open: false }"
    @cart-updated.window="open = true"
>
    <button
        type="button"
        @click="open = !open"
        :aria-expanded="open"
        aria-controls="cart-drawer"
    >
        Cart
    </button>

    <section
        id="cart-drawer"
        x-cloak
        x-show="open"
        x-transition
        @keydown.escape.window="open = false"
    >
        <button type="button" @click="open = false">
            Close cart
        </button>

        <div id="cart-contents">
            <!-- server-owned -->
        </div>
    </section>
</aside>
```

Production requirements may include:

- focus trap if it behaves as a modal;
- focus restoration;
- scroll locking;
- accessible name;
- background interaction policy;
- responsive behavior;
- one animation owner.

Use the Alpine Focus plugin or a tested design-system component when requirements exceed a simple non-modal drawer.

## 7. Inline edit lifecycle

A record row may be replaced completely.

View state:

```html
<article id="customer-42" x-data="{ menuOpen: false }">
    <h2>Ada Lovelace</h2>

    <button type="button" @click="menuOpen = !menuOpen">
        Actions
    </button>

    <div x-cloak x-show="menuOpen">
        <button
            hx-get="/customers/42/edit"
            hx-target="#customer-42"
            hx-swap="outerHTML"
        >
            Edit
        </button>
    </div>
</article>
```

Edit response:

```html
<article id="customer-42">
    <form
        action="/customers/42"
        method="post"
        hx-post="/customers/42"
        hx-target="#customer-42"
        hx-swap="outerHTML"
    >
        <label>
            Name
            <input
                name="name"
                value="Ada Lovelace"
                data-focus-after-swap
            >
        </label>

        <button type="submit">Save</button>
        <button
            type="button"
            hx-get="/customers/42"
            hx-target="#customer-42"
            hx-swap="outerHTML"
        >
            Cancel
        </button>
    </form>
</article>
```

The old Alpine menu state disappears. Correct.

## 8. Focus after inline replacement

A narrow integration listener can focus a marker:

```html
<script>
document.body.addEventListener('htmx:afterSettle', (event) => {
    const target = event.detail.target
    if (!target?.matches?.('[id^="customer-"]')) return

    target.querySelector('[data-focus-after-swap]')?.focus()
})
</script>
```

Cautions:

- keep the selector feature-specific;
- do not focus on polling updates;
- after save, decide whether focus moves to the updated heading, Edit button, or remains near the operation;
- after delete, move focus to a logical neighbor or list heading;
- test browser history restoration.

## 9. Conflict handling

An inline editor may be stale.

Server response should explain:

- what changed;
- whether the user’s draft is preserved;
- how to reload or compare;
- whether resubmission is safe.

Example:

```html
<article id="customer-42">
    <div role="alert">
        This customer was updated by another user.
    </div>

    <form ...>
        <!-- submitted values preserved -->
        <button name="resolution" value="reload" type="submit">
            Reload current record
        </button>
        <button name="resolution" value="overwrite" type="submit">
            Overwrite, if authorized
        </button>
    </form>
</article>
```

Alpine may reveal a local comparison panel. The server owns the conflict decision.

## 10. Overlay and editor SOP

For every remote overlay:

```text
Stable shell:
Inner target:
Open trigger:
Loading behavior:
Initial focus:
Validation focus:
Success fact:
Close behavior:
Focus restoration:
Escape/cancel:
Background interaction:
Error behavior:
```

For every inline editor:

```text
View root:
Edit root:
Swap:
Disposable Alpine state:
Initial focus:
Cancel response:
Validation response:
Conflict response:
Post-delete focus:
```

## 11. Day lab: customer management

Implement:

- a remote native dialog for editing customer details;
- server-rendered validation;
- close-on-success event;
- focus restoration;
- an outside customer row updated via OOB swap;
- a non-modal cart-style drawer;
- an inline status editor that replaces its whole row;
- conflict handling.

Test with keyboard only.

## Mini-tasks

### Task A: repair premature close

A modal closes on `htmx:afterRequest`. Change it to close only on `record-saved`.

### Task B: move the target

A modal root is replaced on every validation error. Keep the shell stable.

### Task C: focus map

Write expected focus for load, validation, success, cancel, delete, and network error.

### Task D: reset intentionally

Explain why an inline row menu should disappear after the row is replaced.

## Comprehension check

1. Who owns modal visibility?
2. Why should the shell stay outside the form target?
3. Why is `<dialog>` a useful baseline?
4. Why is a generic request-complete event insufficient for closing?
5. What should validation failure return?
6. What restores focus after close?
7. When should an inline editor replace its Alpine root?
8. What should happen to focus after deletion?
9. Who owns conflict resolution?
10. Why should polling never steal focus?

## Cheat sheet

```text
MODAL OPEN/CLOSE           -> Alpine/native dialog
REMOTE BODY                -> htmx target
VALIDATION                 -> server-rendered form
CLOSE                      -> explicit server success fact
FOCUS RESTORE              -> remembered opener
INLINE EDIT                -> outerHTML complete row
MENU STATE AFTER SAVE      -> reset
CONFLICT                   -> server outcome + usable HTML
```

## Exit exercise

Build a “review expense” dialog that loads remotely, supports validation and conflict resolution, updates the expense row and summary total, closes only on success, shows a toast, and restores focus to the exact opener.

## Exit criteria

You pass when the complete workflow is usable by keyboard, every focus move is intentional, and no local shell state is destroyed by an inner validation swap.

## Official reference shelf

Use these as the authority when a project version or behavior is uncertain:

- Alpine.js documentation: https://alpinejs.dev/
- Alpine.js directives: https://alpinejs.dev/directives/
- Alpine.js `Alpine.data()` lifecycle and `destroy()`: https://alpinejs.dev/globals/alpine-data
- htmx 2.x documentation: https://htmx.org/docs/
- htmx 2.x reference: https://htmx.org/reference/
- htmx 2.x events: https://htmx.org/events/
- htmx 4 migration guide: https://four.htmx.org/migration-guide-htmx-4/

> Version note: These exercises use htmx 2.x event names such as `htmx:afterSwap` and `htmx:afterSettle`. htmx 4 changes event naming and several defaults. Do not mechanically port the examples without the migration guide.
