# Dialog.Open / Dialog.Ask / Dialog.Confirm / Dialog.Discard

> Opens a component in an overlay above the current screen, with a unit of work you choose: `Inherit` makes its edits a savepoint of the work already open, so Confirm hands them to the surrounding page and that page's Save decides; `Root` gives the dialog its own, so its Confirm is the save. `Dialog.Ask<TEnum>` waits and returns the answer the dialog supplies with `Dialog.Confirm(value)`.

<!-- id: ui-dialogs · area: ui · stability: stable · html: https://osysharp.com/reference/ui/dialogs/ -->

## Summary        {#summary}
A **dialog** is an ordinary component shown in an overlay above the current screen. What makes it a dialog is not how
it looks — you draw that yourself — but the **unit of work** it runs in, which you choose when you open it:

- **`unitOfWork: Inherit`** — the dialog's edits are a *savepoint* of whatever unit of work is already open. Confirm
  hands them to that unit of work: the page goes **dirty**, and **nothing is saved** until the page's own Save.
- **`unitOfWork: Root`** — the dialog owns its unit of work. There is nothing to hand its changes to, so its Confirm
  **is** the save.

`Dialog.Open` shows a dialog and moves on. `Dialog.Ask<TEnum>` shows one and **waits for an answer**.

## Signature      {#signature}
```osy syntax
Dialog.Open(SomeComponent(prop: value), unitOfWork: Inherit);   // show it; don't wait
var answer = Dialog.Ask<Decision>(SomeComponent(…), unitOfWork: Root);   // show it; wait for the answer

Dialog.Confirm();          // inside the dialog: accept, and close
Dialog.Confirm(value);     // …and hand `value` back to whoever called Dialog.Ask
Dialog.Discard();          // inside the dialog: throw its changes away, and close
```

## Description    {#description}

### Choosing the unit of work — the one decision {#unit-of-work}

`unitOfWork:` is **required and has no default**. That is deliberate: the wrong choice loses work *silently*, and
nothing about a component says which it should be.

| | `Inherit` | `Root` |
|---|---|---|
| whose work does it edit? | the surrounding page's | its own |
| what does Confirm do? | promotes into the page's unit of work | commits |
| after Confirm, is it saved? | **no** — the page is dirty; its Save decides | **yes** |
| what does Discard do? | drops the savepoint; the page never knew | drops everything the dialog did |
| when to reach for it | editing something the page is already about | something unrelated to the page — account settings, preferences |

Two examples of the difference, in words. A form about a project, with a button that opens a dialog editing that
project's data: **Inherit**, so pressing OK marks the project dirty and the page's Save is still what persists it. The
avatar in a nav rail opening general settings that have nothing to do with the screen behind: **Root**, so Save in the
dialog saves, and closing the page never had anything to do with it.

⚑ **"Can a `Root` dialog edit a row the PAGE loaded?" — yes, and this is the question people actually ask.** The
table above answers by intent; the mechanical worry underneath it is whether the two units of work fight over the
same row. They do not. An entity is one row, and a `Root` dialog handed one edits it and commits it on `Confirm` —
the page's own queries then read the committed value back like any other change.

So both choices WORK for an edit, and the difference is when it is saved:

| | `Root` | `Inherit` |
|---|---|---|
| pressing OK | saves, then and there | marks the page dirty; the page's Save persists it |
| reach for it when | the press IS the act — "Lend", "Rename", a switch in a settings sheet | the page has its own Save and this edit belongs to it |

⚠ **A page with no Save button and a dialog on `Inherit` is the shape that loses work**: OK promotes the edit into
a unit of work nothing ever commits, and the change is gone at the next navigation with no error anywhere. If the
page does not save, the dialog must.

### A dialog opened from a dialog {#nesting}

`Inherit` means *whatever unit of work is currently open* — not "the page". So a dialog opened from inside another
dialog savepoints off **that** dialog, and its changes ride the outer one's Confirm. Closing the outer dialog without
confirming discards the inner one's work too, which is what you want: the user never left the sheet they were in.

### What the page's lists see while a dialog is open {#lists}

A list reads through the unit of work it was mounted in. The page's lists read the **page's**; a dialog's own lists
read the **dialog's**, which on `Inherit` is a savepoint that also sees everything pending in the page. So a row a
dialog creates or edits is:

| | the dialog's own lists | the page's lists |
|---|---|---|
| while the dialog is open | shown | **not shown** — filtered or not |
| after `Dialog.Discard()` | gone | never were |
| after `Dialog.Confirm()` on `Inherit` | — | pending in the page: on its lists of every row of the type, not yet on a filtered one |
| after the page's `UnitOfWork.Commit()` | — | on exactly the lists whose filter matches, from each list's own re-read |

The "filtered or not" is the other half of the rule ([creating & saving data](https://osysharp.com/reference/ui/data-mutation/)): an unsaved row joins only a list of
**every** row of its type (`Ticket.ToList()`, ordered or with includes), because the filter of any other list runs
on the server and the browser cannot tell whether a new row belongs in it. That holds in every unit of work, the
dialog's included.

This is what makes a create or edit form in a dialog the shape to reach for: nothing half-typed reaches the page, and
Cancel is a real cancel.

```osy title="a-create-form-in-its-own-unit-of-work" test app=ui-dialog-create-draft
entity Ticket { [MaxLength(120)] string Title; bool Open; }

[Render(CSR)]
component NewTicket() {
  Ticket draft = new Ticket { Open = true };      // pending in the DIALOG's unit of work, not the page's
  action Add()    { Dialog.Confirm(); }           // hand it to the page: the page's Save persists it
  action Cancel() { Dialog.Discard(); }           // gone, and the page never saw it
  render {
    Stack(role: UiRole.Dialog, label: "New ticket", gap: 2) {
      Input(value: draft.Title, placeholder: "What is wrong?");
      Button("Cancel", onPress: Cancel);
      Button("Add", onPress: Add);
    }
  }
}

[Route("/tickets")]
[Render(CSR)]
component Tickets() {
  var open = Ticket.Where(t => t.Open).ToList();  // shows the new ticket once Save's re-read answers
  action New()  { Dialog.Open(NewTicket(), unitOfWork: Inherit); }
  action Save() { UnitOfWork.Commit(); }
  render {
    Stack(gap: 2) {
      foreach (var t in open) { Text(t.Title); }
      Button("New ticket", onPress: New);
      Button("Save", onPress: Save);
    }
  }
}
```

### Waiting for an answer — `Dialog.Ask<TEnum>`   {#ask}

`Dialog.Ask<TEnum>` shows a dialog and **waits**. The dialog answers with `Dialog.Confirm(value)`, and that value is
what `Ask` evaluates to.

The type argument is an **enum you declare**. A dialog asks a question, and a question has a known set of answers —
which is exactly what an enum is. It also means the caller's `== Decision.Delete` is checked when you compile rather
than compared against a string nobody declared. A dialog dismissed with `Dialog.Discard()` answers nothing, so treat
"no answer" as its own case.

`Ask` works from a **server function** too, which is what makes a per-row question possible: a function can read rows,
loop over them, ask about each one, and carry on with the answers. The run suspends at each question, the browser
shows the dialog, and the run resumes where it left off with its data intact. The flow must have been **started by
someone using the app** — a scheduled or workflow-driven run has nobody to ask.

### What the platform draws — nothing {#look}

The platform contributes exactly two things: a host element that is already an overlay above the page, and the unit
of work. It draws **no** backdrop, panel, buttons or animation.

That is why a dialog can look like anything — a small confirmation, or a full settings surface with its own nav rail.
Your component owns the dim, the panel, its width, its corners and its shadow, exactly as it owns any other screen.
You do **not** need to position it: being above the page is the platform's job.

#### Two things you owe it, and neither is visual {#owed}

Drawing it yourself means you also own what makes it a *dialog* rather than a box that happens to float. Both of
these are invisible on screen and both fail loudly later, so they are the first two lines of the panel, not a polish
pass:

```osy title="a panel you draw yourself" test app=ui-dialog-own-panel
entity Locomotive { [MaxLength(80)] string Name; bool Retired; }

[AllowAnonymous]                                    // ① or the fetch 403s — see below
[Render(CSR)]
component ConfirmRetire(Locomotive engine) {
  // ③ a handler is an ACTION of this component, passed by NAME. `onPress: Dialog.Discard`
  //    reads perfectly and does not compile — see below.
  action Cancel() { Dialog.Discard(); }
  action Retire() { engine.Retired = true; Dialog.Confirm(); }

  render {
    Stack(role: UiRole.Dialog, label: "Retire this engine?") {  // ② or no test can reach inside it
      Text("Retire this engine?", fontSize: FontSize.Heading);
      Row(gap: 2, justify: Justify.End) {
        Button("Cancel", onPress: Cancel);
        Button("Retire", onPress: Retire, tone: Tone.Danger);
      }
    }
  }
}

[Route("/engines")]
[AllowAnonymous]
[Render(CSR)]
component Engines() {
  var engines = Locomotive.ToList();
  action Ask(Locomotive e) { Dialog.Open(ConfirmRetire(engine: e), unitOfWork: Root); }
  render { Stack(gap: 2) { foreach (var e in engines) { Button(e.Name, onPress: () => Ask(e)); } } }
}
```

**① `[AllowAnonymous]` on the component itself, in an app with no sign-in.** `Dialog.Open` fetches its component as
its own entry point, so the page's marker does not cover it. Without one the fetch answers **403** and the button
appears to do nothing — there is no error on screen, because the failure is in a request the page made on your
behalf. The lint `ui-dialog-target-unreachable` now names this before you run.

**② `role: UiRole.Dialog` and `label:` on the panel.** The role is what an assistive reader announces, and the label
is the container's NAME — which is what `within:` selects. Omit them and `Assert.Dialog(…)` reports *"a dialog IS
open … but nothing in it carries an accessible name"*, and every `Ui.Click("Save", within: "…")` inside it has
nothing to match.

**③ `onPress:` takes an ACTION of this component, by name.** `onPress: Dialog.Discard` is the spelling everyone
reaches for and it does not compile — `Dialog` is also a member of the `UiRole` enum, so the refusal you get talks
about `UiRole.Dialog`, which is not the problem and cannot be the fix. Give the dialog a one-line action and pass
that: `action Cancel() { Dialog.Discard(); }` … `onPress: Cancel`.

> ⛔ **This page taught the broken spelling until 2026-09-01, in a `syntax` fence the compile gate does not check.**
> Eval run 16 copied it into FOUR dialogs and spent fourteen calls — a quarter of the run — hunting a `Dialog`
> namespace that does not exist. The fence above is COMPILED now, so it cannot say that again.

> ⚑ **Measured, and this section exists because the page caused it.** Two eval runs read this page, drew their own
> panel exactly as it invited, and shipped the same defect — one of them three times in one file, then spent eight
> consecutive lookups discovering the 403. A third run skipped the page, ran `osy kit Dialog`, used the kit control,
> and its first `osy check` passed. The kit's source carries both facts; this page did not, so the runs that did the
> diligent thing were the ones that got it wrong. **If you do not want to own these two, use the kit control**
> ([below](#kit-control)) — it sets both for you.

### The kit's `Dialog` control is a different thing {#kit-control}

The bundled UI kit ships a control also called `Dialog` — `Dialog(title, onDismiss, subtitle, size)`, `osy kit Dialog`. It
is **not** what this page documents, and the two are not layers of one mechanism:

| | `Dialog.Open(…)` — this page | the kit's `Dialog(…)` control |
|---|---|---|
| what it is | an effect that opens a component in the platform's overlay | markup — a `Scrim` and a panel, drawn in the page's own tree |
| how it appears | you call it | you render it conditionally: `if (confirming) { Dialog("Delete?", onDismiss: Cancel) { … } }` |
| unit of work | its own, and you choose it: `Inherit` or `Root` | **none** — being `[Composable]` it has no mount of its own, so there is nothing to savepoint. Whatever you edit inside it is edited in the **page's** unit of work |
| how it closes | `Dialog.Confirm()` / `Dialog.Discard()` | the `onDismiss` action you passed, which flips your own flag |

**How wide it is** is the control's `size:` — the width follows what is in it, and every size is capped to the
window:

| `size:` | width | for |
|---|---|---|
| `DialogSize.Small` | 400px | a yes or no |
| `DialogSize.Medium` (the default) | 480px | a confirmation, a short form |
| `DialogSize.Large` | 720px | a form with several fields, a list to pick from |
| `DialogSize.Wide` | 1040px | something to **read** or work in: a document, a wide table |

**It never grows past the window.** A body taller than the window scrolls *inside* the dialog, with the title above
it and the `actions` row below it both staying in view — so the button that closes it is always reachable.

So the kit control is a panel that looks like a dialog; it does not give you the scope this page is about. If the
edits inside it must be discardable, or must not touch the page until they are confirmed, open a component with
`Dialog.Open` and choose its unit of work — a kit `Dialog` around the same fields cannot do it, open or closed.

### The keyboard — a modal dialog holds it {#keyboard}

Both kinds are MODAL — a window over the page — and the platform treats them so: as one opens, the keyboard goes to its
first control (to the dialog itself when it has none); **Tab and Shift+Tab stay inside it**, wrapping at its ends; and
as it closes, the keyboard goes back to whatever opened it — the button pressed, or, when a MENU item opened it, that
menu's trigger (the item left with its menu). A field inside that asked for focus (`autoFocus:`) keeps it. Escape
closes the kit's `Dialog` through its scrim. Nothing here is written by the app.

What makes an element modal is its role: `role: UiRole.ModalDialog` (`role="dialog" aria-modal="true"`). The kit's
`Dialog` and the shells' phone sheets say it; a `Dialog.Open` host is modal by construction. A plain
`role: UiRole.Dialog` is a POPOVER — a date picker under its field, a panel beside its button — and is left alone,
because pulling the keyboard into one that opened as a field took focus would steal the typing. A modal of your own is
one word:

```osy title="a modal of the app's own: the role is what makes the platform hold the keyboard" test app=ui-dialogs-modal-role
[Route("/")]
[AllowAnonymous]
component Board() {
  bool asking = false;
  string reason = "";
  action Ask() { asking = true; }
  action Close() { asking = false; }
  render {
    Stack(gap: 3, p: 6) {
      Button("Close the ticket", onPress: Ask);
      if (asking) {
        Scrim(Close);
        Box(position: Position.Fixed, top: "0", left: "0", right: "0", bottom: "0", z: ZIndex.Modal,
            display: Display.Flex, align: Align.Center, justify: Justify.Center, pointerEvents: PointerEvents.None) {
          Stack(role: UiRole.ModalDialog, label: "Close the ticket", gap: 3, p: 5, bg: Colors.Surface,
                rounded: Radius.Lg, shadow: Shadow.Deep, maxH: "90dvh", pointerEvents: PointerEvents.Auto) {
            Field("Reason", value: reason);
            Row(gap: 2) { Button("Cancel", onPress: Close); Button("Close it", onPress: Close, tone: Tone.Primary); }
          }
        }
      }
    }
  }
}
```

In a test: `Ui.Click("Close the ticket"); Assert.Focused("Reason"); Ui.Press("Shift+Tab"); Assert.Focused("Close it");`
and, after `Ui.Press("Escape")` or a close, `Assert.Focused("Close the ticket")`.

The panel above sits in the middle of the window, which is where the kit's `Dialog` puts its own. The centring
layer covers the whole window, so it takes `pointerEvents: PointerEvents.None` and lets a press beside the panel
fall through to the `Scrim`, which is what dismisses. The panel takes the pointer back with `PointerEvents.Auto`, and
`maxH: "90dvh"` keeps it inside the window on a phone whose address bar comes and goes.

### The page behind a modal does not scroll {#page-held}

While anything modal is open — a `Dialog.Open` host, the kit's `Dialog`, or an element of your own with
`role: UiRole.ModalDialog` — **the page behind it is held still**: the wheel, a swipe and the keyboard scroll the
dialog's own content, never the dimmed page. Nested modals share one hold, and it ends when the last one closes.
The page is exactly where it was: its scroll position is not touched.

This is the platform's doing, for every modal, because a render tree cannot reach the document. What stays yours in
a modal you draw yourself is the other half: cap its height (`maxH: "90dvh"`) and let its content scroll
(`overflowY: Overflow.Auto`, `minH: "0"` on the part that scrolls). The kit's `Dialog` does both.

### Saving a Root dialog {#root-save}

A `Root` dialog's Confirm is the only thing that can persist it. A root dialog that edits data and has no reachable
`Dialog.Confirm()` can therefore only ever be discarded — every edit thrown away, with nothing reporting it. `osy
lint` reports that shape as `data-root-dialog-cannot-confirm`; either give it a Confirm, or open it with `Inherit` so
its edits ride the surrounding page's save.

## Examples       {#examples}

An **Inherit** dialog editing a row the page is about. `OK` makes the *page* dirty; the page's own Save is what
persists it:

```osy title="edit-in-a-dialog" test app=ui-dialog-inherit
entity Project { [MaxLength(80)] string Name; }

[Render(CSR)]
component EditProject(Project project) {
  action Ok()     { Dialog.Confirm(); }
  action Cancel() { Dialog.Discard(); }
  render {
    Stack(gap: 2) {
      Input(value: project.Name, placeholder: "Project name");
      Button("Cancel", onPress: Cancel);
      Button("OK", onPress: Ok);
    }
  }
}

[Route("/projects")]
[Render(CSR)]
component Projects() {
  var projects = Project.ToList();
  action Edit(Project p) { Dialog.Open(EditProject(project: p), unitOfWork: Inherit); }
  action Save() { UnitOfWork.Commit(); }
  render {
    Stack(gap: 2) {
      foreach (var p in projects) { Button(p.Name, onPress: () => Edit(p)); }
      Button("Save", onPress: Save);
    }
  }
}
```

**Asking a question** and acting on the answer. The answer type is an enum you declare:

```osy title="ask-before-deleting" test app=ui-dialog-ask
enum Decision { Delete, Keep }

entity Note { [MaxLength(80)] string Title; }

[Render(CSR)]
component ConfirmDelete(Note note) {
  action Choose(Decision d) { Dialog.Confirm(d); }
  render {
    Stack(gap: 2) {
      Text("Delete " + note.Title + "?");
      Button("Keep it", onPress: () => Choose(Decision.Keep));
      Button("Delete", onPress: () => Choose(Decision.Delete));
    }
  }
}

[Route("/notes")]
[Render(CSR)]
component Notes() {
  var notes = Note.ToList();
  action Remove(Note n) {
    if (Dialog.Ask<Decision>(ConfirmDelete(note: n), unitOfWork: Inherit) == Decision.Delete) {
      n.Delete();
      UnitOfWork.Commit();
    }
  }
  render {
    Stack(gap: 2) { foreach (var n in notes) { Button(n.Title, onPress: () => Remove(n)); } }
  }
}
```

A **Root** dialog — settings that have nothing to do with the page behind, so they own their unit of work and their
Save really saves:

```osy title="settings-in-its-own-unit-of-work" test app=ui-dialog-root
entity Preferences { [MaxLength(40)] string DisplayName; }

[Render(CSR)]
component Settings(Preferences prefs) {
  action Save()  { Dialog.Confirm(); }
  action Close() { Dialog.Discard(); }
  render {
    Stack(gap: 2) {
      Input(value: prefs.DisplayName, placeholder: "How you appear");
      Button("Close", onPress: Close);
      Button("Save", onPress: Save);
    }
  }
}

[Route("/settings")]
[Render(CSR)]
component SettingsPage() {
  var prefs = Preferences.FirstOrDefault();
  action Open() { Dialog.Open(Settings(prefs: prefs), unitOfWork: Root); }
  render { Stack(gap: 2) { Button("Settings…", onPress: Open); } }
}
```

Drawing the look. The platform positions the overlay; everything you can see is yours:

```osy title="the platform positions the overlay; the panel look is yours" syntax
render {
  Row(align: Align.Center, justify: Justify.Center, w: "100%", h: "100%", bg: Colors.Scrim) {
    Stack(gap: 4, w: "420px", p: 5, bg: Colors.Surface, rounded: Radius.Lg, borderW: 1, border: Colors.Border, shadow: Shadow.Deep) {
      // …your dialog…
    }
  }
}
```

## See also       {#see-also}
- [creating & saving data](https://osysharp.com/reference/ui/data-mutation/) — the unit of work a dialog savepoints, what `UnitOfWork.Commit()` / `Discard()` do, and
  which lists an unsaved row joins
- [component](https://osysharp.com/reference/ui/component/) — a dialog is an ordinary component; this is what one is
- [routes and pages](https://osysharp.com/reference/ui/routing/) — the other way to hold work in progress: a route, with its own unit of work
- [style props](https://osysharp.com/reference/ui/styling/) — the style props the panel above uses
- `osy kit Dialog` — the kit's same-named CONTROL, which draws a panel and carries no unit of work (see [above](#kit-control))
