diff --git a/docs/examples/widgets/dialog_complex.py b/docs/examples/widgets/dialog_complex.py
new file mode 100644
index 0000000000..d116350c1b
--- /dev/null
+++ b/docs/examples/widgets/dialog_complex.py
@@ -0,0 +1,23 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, Checkbox, Dialog, Input, Label
+
+
+class DialogApp(App[None]):
+ CSS_PATH = "dialog_complex.tcss"
+
+ def compose(self) -> ComposeResult:
+ with Dialog(title="Find and replace"):
+ yield Label("Find what:") # (1)!
+ yield Input(placeholder="The text to find") # (2)!
+ yield Label("Replace with:") # (3)!
+ yield Input(placeholder="The text to replace with") # (4)!
+ with Dialog.ActionArea(): # (5)!
+ with Dialog.ActionArea.GroupLeft(): # (6)!
+ yield Checkbox("Ignore case") # (7)!
+ yield Button("Cancel", variant="error") # (8)!
+ yield Button("First", variant="default") # (9)!
+ yield Button("All", variant="primary") # (10)!
+
+
+if __name__ == "__main__":
+ DialogApp().run()
diff --git a/docs/examples/widgets/dialog_complex.tcss b/docs/examples/widgets/dialog_complex.tcss
new file mode 100644
index 0000000000..4ecd9838dd
--- /dev/null
+++ b/docs/examples/widgets/dialog_complex.tcss
@@ -0,0 +1,19 @@
+Screen {
+ align: center middle;
+
+ Dialog {
+
+ Input {
+ width: 1fr;
+ margin: 0 0 1 0;
+ }
+
+ Label {
+ margin: 0 0 0 1;
+ }
+
+ ActionArea Button {
+ min-width: 11;
+ }
+ }
+}
diff --git a/docs/examples/widgets/dialog_error.py b/docs/examples/widgets/dialog_error.py
new file mode 100644
index 0000000000..2d27d78f21
--- /dev/null
+++ b/docs/examples/widgets/dialog_error.py
@@ -0,0 +1,27 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, Dialog, Label
+
+
+class ErrorDialogApp(App[None]):
+ CSS_PATH = "dialog_variants.tcss"
+
+ def compose(self) -> ComposeResult:
+ with Dialog.error(title="Emergency Transmission"):
+ yield Label(
+ "This is the President of the United Federation of "
+ "Planets. Do not approach the Earth. The transmissions of an "
+ "orbiting probe are causing critical damage to this planet. "
+ "It has almost totally ionized our atmosphere. All power "
+ "sources have failed. All Earth-orbiting starships are "
+ "powerless. The probe is vaporizing our oceans. We cannot "
+ "survive unless a way can be found to respond to the probe. "
+ "Further communications may not be possible. Save your "
+ "energy."
+ )
+ with Dialog.ActionArea():
+ yield Button("Obey")
+ yield Button("Ignore")
+
+
+if __name__ == "__main__":
+ ErrorDialogApp().run()
diff --git a/docs/examples/widgets/dialog_simple.py b/docs/examples/widgets/dialog_simple.py
new file mode 100644
index 0000000000..d6cc2a13e9
--- /dev/null
+++ b/docs/examples/widgets/dialog_simple.py
@@ -0,0 +1,17 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, Dialog, Label
+
+
+class DialogApp(App[None]):
+ CSS_PATH = "dialog_simple.tcss"
+
+ def compose(self) -> ComposeResult:
+ with Dialog(title="Greetings Professor Falken"): # (1)!
+ yield Label("Shall we play a game?") # (2)!
+ with Dialog.ActionArea(): # (3)!
+ yield Button("Love to!") # (4)!
+ yield Button("No thanks") # (5)!
+
+
+if __name__ == "__main__":
+ DialogApp().run()
diff --git a/docs/examples/widgets/dialog_simple.tcss b/docs/examples/widgets/dialog_simple.tcss
new file mode 100644
index 0000000000..87b9fc77f0
--- /dev/null
+++ b/docs/examples/widgets/dialog_simple.tcss
@@ -0,0 +1,3 @@
+Screen {
+ align: center middle;
+}
diff --git a/docs/examples/widgets/dialog_styling.py b/docs/examples/widgets/dialog_styling.py
new file mode 100644
index 0000000000..ef8125b204
--- /dev/null
+++ b/docs/examples/widgets/dialog_styling.py
@@ -0,0 +1,27 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, Dialog, Label
+
+
+class StyledDialogApp(App[None]):
+ CSS_PATH = "dialog_styling.tcss"
+
+ def compose(self) -> ComposeResult:
+ with Dialog.error(title="Emergency Transmission"):
+ yield Label(
+ "This is the President of the United Federation of "
+ "Planets. Do not approach the Earth. The transmissions of an "
+ "orbiting probe are causing critical damage to this planet. "
+ "It has almost totally ionized our atmosphere. All power "
+ "sources have failed. All Earth-orbiting starships are "
+ "powerless. The probe is vaporizing our oceans. We cannot "
+ "survive unless a way can be found to respond to the probe. "
+ "Further communications may not be possible. Save your "
+ "energy."
+ )
+ with Dialog.ActionArea():
+ yield Button("Obey")
+ yield Button("Ignore")
+
+
+if __name__ == "__main__":
+ StyledDialogApp().run()
diff --git a/docs/examples/widgets/dialog_styling.tcss b/docs/examples/widgets/dialog_styling.tcss
new file mode 100644
index 0000000000..9896b7c514
--- /dev/null
+++ b/docs/examples/widgets/dialog_styling.tcss
@@ -0,0 +1,17 @@
+Screen {
+ align: center middle;
+}
+
+Dialog {
+
+ Body {
+ border: thick red;
+ background: red 40%;
+ color: $text;
+ padding: 1 2;
+ }
+
+ Label {
+ width: 1fr;
+ }
+}
diff --git a/docs/examples/widgets/dialog_success.py b/docs/examples/widgets/dialog_success.py
new file mode 100644
index 0000000000..4c473030d2
--- /dev/null
+++ b/docs/examples/widgets/dialog_success.py
@@ -0,0 +1,16 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, Dialog, Label
+
+
+class SuccessDialogApp(App[None]):
+ CSS_PATH = "dialog_variants.tcss"
+
+ def compose(self) -> ComposeResult:
+ with Dialog.success(title="Search Successful"):
+ yield Label("Admiral. We have found the nuclear wessel.")
+ with Dialog.ActionArea():
+ yield Button("OK")
+
+
+if __name__ == "__main__":
+ SuccessDialogApp().run()
diff --git a/docs/examples/widgets/dialog_variants.tcss b/docs/examples/widgets/dialog_variants.tcss
new file mode 100644
index 0000000000..7365f9983c
--- /dev/null
+++ b/docs/examples/widgets/dialog_variants.tcss
@@ -0,0 +1,7 @@
+Screen {
+ align: center middle;
+}
+
+Dialog Label {
+ width: 1fr;
+}
diff --git a/docs/examples/widgets/dialog_warning.py b/docs/examples/widgets/dialog_warning.py
new file mode 100644
index 0000000000..56acd8bf62
--- /dev/null
+++ b/docs/examples/widgets/dialog_warning.py
@@ -0,0 +1,20 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Button, Dialog, Label
+
+
+class WarningDialogApp(App[None]):
+ CSS_PATH = "dialog_variants.tcss"
+
+ def compose(self) -> ComposeResult:
+ with Dialog.warning(title="Are you sure?"):
+ yield Label(
+ "Admiral, if we were to assume these whales were ours to do with as we pleased, "
+ "we would be as guilty as those who caused their extinction."
+ )
+ with Dialog.ActionArea():
+ yield Button("OK")
+ yield Button("Cancel")
+
+
+if __name__ == "__main__":
+ WarningDialogApp().run()
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index ca82b5d4e3..94889f29e4 100644
--- a/docs/widget_gallery.md
+++ b/docs/widget_gallery.md
@@ -62,6 +62,15 @@ A powerful data table, with configurable cursors.
```{.textual path="docs/examples/widgets/data_table.py"}
```
+## Dialog
+
+A widget for laying out a classic dialog.
+
+[Dialog reference](./widgets/dialog.md){ .md-button .md-button--primary}
+
+```{.textual path="docs/examples/widgets/dialog_complex.py"}
+```
+
## Digits
Display numbers in tall characters.
diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md
new file mode 100644
index 0000000000..fd76625094
--- /dev/null
+++ b/docs/widgets/dialog.md
@@ -0,0 +1,204 @@
+# Dialog
+
+!!! tip "Added in version x.y.z"
+
+A container widget designed to help build classic dialog layouts.
+
+- [ ] Focusable
+- [X] Container
+
+## Guide
+
+### Composing a `Dialog`
+
+The `Dialog` widget helps with laying out a classic dialog, one with
+"content" widgets in the main body, and with a horizontal area of "action
+items" (which will normally be buttons) at the bottom. This is ideally done
+while [composing with a context
+manager](../guide/layout/#composing-with-context-managers). For example:
+
+=== "dialog_simple.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/dialog_simple.py"
+ ~~~
+
+ 1. All widgets composed within here will be part of the dialog.
+ 2. The label is part of the body of the dialog.
+ 3. This introduces the area of horizontal-layout widgets at the bottom of the dialog.
+ 4. This button goes into the action area.
+ 5. This button goes into the action area.
+
+=== "dialog_simple.tcss"
+
+ ```css
+ --8<-- "docs/examples/widgets/dialog_simple.tcss"
+ ```
+
+This is how the resulting dialog looks:
+
+```{.textual path="docs/examples/widgets/dialog_simple.py"}
+```
+
+The items in the `ActionArea` of a dialog are aligned to the right; but it
+is a common approach to include one or more widgets in the action area that
+are aligned to the left. This can be done too:
+
+The dialog can contain any Textual widget, and the "action area" (where
+buttons ordinarily live) can contain other widgets too. For example:
+
+=== "dialog_complex.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/dialog_complex.py"
+ ~~~
+
+ 1. This widget goes into the main body of the dialog.
+ 2. This widget goes into the main body of the dialog.
+ 3. This widget goes into the main body of the dialog.
+ 4. This widget goes into the main body of the dialog.
+ 5. This introduces the area of horizontal-layout widgets at the bottom of the dialog.
+ 6. Widgets within this group will be aligned to the left.
+ 7. The `Checkbox` will be on the left of the action area.
+ 8. The `Button` will be to the right of the action area.
+ 9. The `Button` will be to the right of the action area.
+ 10. The `Button` will be to the right of the action area.
+
+=== "dialog_complex.tcss"
+
+ ```css
+ --8<-- "docs/examples/widgets/dialog_complex.tcss"
+ ```
+
+The resulting dialog looks like this:
+
+```{.textual path="docs/examples/widgets/dialog_complex.py"}
+```
+
+### Dialog variants
+
+Much like with [`Button`](./button.md) the `Dialog` widget has variants;
+these provide ready-made semantic styles for your dialogs. As well as
+`default`, there are also:
+
+#### "success"
+
+A `success` variant `Dialog` can be created by either passing `"success"` as
+the `variant` parameter when [creating the
+`Dialog`](#textual.widgets.Dialog), by setting the [`variant`
+reactive](#textual.widgets._dialog.Dialog.variant) to `"success"`, or by
+calling the [`Dialog.success`
+constructor](#textual.widgets._dialog.Dialog.success).
+
+=== "Success variant dialog"
+
+ ```{.textual path="docs/examples/widgets/dialog_success.py"}
+ ```
+
+=== "dialog_success.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/dialog_success.py"
+ ~~~
+
+#### "warning"
+
+A `warning` variant `Dialog` can be created by either passing `"warning"` as
+the `variant` parameter when [creating the
+`Dialog`](#textual.widgets.Dialog), by setting the [`variant`
+reactive](#textual.widgets._dialog.Dialog.variant) to `"warning"`, or by
+calling the [`Dialog.warning`
+constructor](#textual.widgets._dialog.Dialog.warning).
+
+=== "Warning variant dialog"
+
+ ```{.textual path="docs/examples/widgets/dialog_warning.py"}
+ ```
+
+=== "dialog_warning.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/dialog_warning.py"
+ ~~~
+
+#### "error"
+
+An `error` variant `Dialog` can be created by either passing `"error"` as
+the `variant` parameter when [creating the
+`Dialog`](#textual.widgets.Dialog), by setting the [`variant`
+reactive](#textual.widgets._dialog.Dialog.variant) to `"error"`, or by
+calling the [`Dialog.error`
+constructor](#textual.widgets._dialog.Dialog.error).
+
+=== "Error variant dialog"
+
+ ```{.textual path="docs/examples/widgets/dialog_error.py"}
+ ```
+
+=== "dialog_error.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/dialog_error.py"
+ ~~~
+
+## Styling the Dialog
+
+The `Dialog` will always contain one sub-widget, called `Body`; this is
+where the widgets that are not in the
+[`ActionArea`](#textual.widgets._dialog.Dialog.ActionArea) are held. If you
+wish to style the main body you can target `Dialog Body` in your CSS:
+
+=== "dialog_styling.tcss"
+
+ ~~~css hl_lines="7-12"
+ --8<-- "docs/examples/widgets/dialog_styling.tcss"
+ ~~~
+
+=== "Styled dialog body"
+
+ ```{.textual path="docs/examples/widgets/dialog_styling.py"}
+ ```
+
+=== "dialog_styling.py"
+
+ ~~~python
+ --8<-- "docs/examples/widgets/dialog_styling.py"
+ ~~~
+
+
+## Reactive Attributes
+
+| Name | Type | Default | Description |
+|-----------|-----------------|-------------|----------------------------------------------------------------------------|
+| `title` | `str` | `""` | The title of the dialog. |
+| `variant` | `DialogVariant` | `"default"` | Semantic styling variant. One of `default`, `success`, `warning`, `error`. |
+
+## Messages
+
+This widget posts no messages.
+
+## Bindings
+
+This widget has no bindings.
+
+## Component classes
+
+This widget has no component classes.
+
+
+## See also
+
+- [ModalScreen](../guide/screens.md#modal-screens)
+
+
+---
+
+
+::: textual.widgets.Dialog
+ options:
+ heading_level: 2
+
+::: textual.widgets.dialog
+ options:
+ show_root_heading: true
+ show_root_toc_entry: true
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index a5ab15880a..7becb372c8 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -138,6 +138,7 @@ nav:
- "widgets/collapsible.md"
- "widgets/content_switcher.md"
- "widgets/data_table.md"
+ - "widgets/dialog.md"
- "widgets/digits.md"
- "widgets/directory_tree.md"
- "widgets/footer.md"
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index cd6e21f13b..e219535c20 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -15,6 +15,7 @@
from ._collapsible import Collapsible
from ._content_switcher import ContentSwitcher
from ._data_table import DataTable
+ from ._dialog import Dialog
from ._digits import Digits
from ._directory_tree import DirectoryTree
from ._footer import Footer
@@ -52,6 +53,7 @@
"Collapsible",
"ContentSwitcher",
"DataTable",
+ "Dialog",
"Digits",
"DirectoryTree",
"Footer",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index d4db2f8f52..3686f0c738 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -4,6 +4,7 @@ from ._checkbox import Checkbox as Checkbox
from ._collapsible import Collapsible as Collapsible
from ._content_switcher import ContentSwitcher as ContentSwitcher
from ._data_table import DataTable as DataTable
+from ._dialog import Dialog as Dialog
from ._digits import Digits as Digits
from ._directory_tree import DirectoryTree as DirectoryTree
from ._footer import Footer as Footer
diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py
new file mode 100644
index 0000000000..953cf443e5
--- /dev/null
+++ b/src/textual/widgets/_dialog.py
@@ -0,0 +1,491 @@
+"""Provides a dialog widget."""
+
+from __future__ import annotations
+
+from typing import get_args
+
+from typing_extensions import Final, Literal
+
+from ..app import ComposeResult
+from ..containers import VerticalScroll
+from ..css._error_tools import friendly_list
+from ..css.query import NoMatches
+from ..geometry import Size
+from ..reactive import var
+from ..widget import Widget
+
+_MAX_DIALOG_DIMENSION: Final[float] = 0.9
+"""The ideal maximum dimension for the dialog in respect to the screen.
+
+This is currently being used inside get_content_width/height in the code
+below, as a stand-in for being able to max-width/height to 90% while also
+doing a lot of `auto` sizing. Eventually, once we can support this via CSS,
+this can go away.
+"""
+
+DialogVariant = Literal["default", "success", "warning", "error"]
+"""The names of the valid dialog variants.
+
+These are the variants that can be used with a [`Dialog`][textual.widgets.Dialog].
+
+See also:
+
+- [`Dialog.success`][textual.widgets.Dialog.success]
+- [`Dialog.warning`][textual.widgets.Dialog.warning]
+- [`Dialog.error`][textual.widgets.Dialog.error]
+"""
+
+
+class Body(VerticalScroll, can_focus=False):
+ """Internal dialog container class for the main body of the dialog."""
+
+ DEFAULT_CSS = """
+ Body {
+ width: auto;
+ height: auto;
+ }
+ """
+
+ def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
+ """Get the ideal height for the body of the dialog.
+
+ Args:
+ container: The container size.
+ viewport: The viewport.
+ width: The content width.
+
+ Returns:
+ The height of the body portion of the dialog (in lines).
+
+ Ideally we'd want this widget to be `height: auto` until it would be
+ too tall for the maximum height of the dialog, minus the height
+ needed for the [`ActionArea`][textual.widgets.Dialog.ActionArea]. At
+ the moment there's no simple method of doing this in with Textual's
+ CSS.
+
+ So in this widget we simply set the `height` to `auto` and then
+ constrain the maximum height with this method; the idea being that
+ the content height will be capped at the maximum height that will
+ push the container to what we want its `max-height` to be, and no
+ further.
+ """
+ height = super().get_content_height(container, viewport, width)
+ if isinstance(self.parent, Dialog):
+ try:
+ # See if the dialog has an ActionArea.
+ action_area = self.parent.get_child_by_type(Dialog.ActionArea)
+ except NoMatches:
+ # It's fine if it doesn't; that just means we don't need to
+ # take it into account for this calculation to take place.
+ pass
+ else:
+ height = min(
+ # Use the minimum of either the actual content height...
+ height,
+ # ...or maximum fraction of the screen...
+ int(self.screen.size.height * _MAX_DIALOG_DIMENSION)
+ # ...minus the full height of the ActionArea...
+ - action_area.outer_size.height
+ # ...and minus the size of the "non-content" parts of
+ # the container.
+ - (self.parent.outer_size.height - self.parent.size.height),
+ )
+ return height
+
+
+class Dialog(Widget):
+ """A dialog widget.
+
+ `Dialog` is a container class that helps provide a classic dialog layout
+ for other widgets.
+ """
+
+ DEFAULT_CSS = """
+ Dialog {
+
+ layout: vertical;
+
+ width: auto;
+ height: auto;
+ max-width: 90%;
+ /*max-height: 90%; Using the get_content_height hack above instead. */
+
+ border: panel $primary;
+ border-title-color: $accent;
+ background: $surface;
+
+ padding: 1 1 0 1;
+
+ ActionArea {
+
+ layout: horizontal;
+ align: right middle;
+
+ /* Action area should perfectly wrap its content. */
+ height: auto;
+ width: auto;
+
+ border-top: $primary;
+
+ padding: 1 0 0 0;
+
+ /* The developer may place widgets directly into the action
+ area; they will likely do this half expecting that there will be
+ a bit of space between each of the widgets. Let's help them with
+ that. */
+ &> * {
+ margin-left: 1;
+ }
+
+ &> GroupLeft {
+
+ layout: horizontal;
+ align: left middle;
+
+ /* The grouping container should perfectly wrap its content. */
+ height: auto;
+ width: auto;
+ dock: left;
+
+ /* The rule above for all items in the ActionArea will give
+ this grouping container a left margin too; but we don't want
+ that. */
+ margin-left: 0;
+ }
+
+ /* Devs will likely compose labels into the ActionArea,
+ expecting that they'll vertically line up with most other things
+ (most of those other) things going in often being 3 cells high.
+ So let's be super helpful here and have a default styling that
+ just does the right thing out of the box. */
+ Label {
+ height: 100%;
+ content-align: center middle;
+ }
+ }
+
+ /***
+ * Styling exceptions for each of the variants.
+ */
+
+ &.-success {
+ border: panel $success;
+ border-title-color: initial;
+
+ ActionArea {
+ border-top: $success;
+ }
+ }
+
+ &.-warning {
+ border: panel $warning;
+ border-title-color: initial;
+
+ ActionArea {
+ border-top: $warning;
+ }
+ }
+
+ &.-error {
+ border: panel $error;
+ border-title-color: initial;
+
+ ActionArea {
+ border-top: $error;
+ }
+ }
+ }
+ """
+
+ variant: var[DialogVariant] = var("default")
+ """The variant of dialog."""
+
+ title: var[str] = var("")
+ """The title of the dialog."""
+
+ def __init__(
+ self,
+ *children: Widget,
+ title: str = "",
+ variant: DialogVariant = "default",
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> None:
+ """Initialise the dialog widget.
+
+ Args:
+ children: The child widgets for the dialog.
+ title: The title for the dialog.
+ variant: The variant of the dialog.
+ name: The name of the dialog.
+ id: The ID of the dialog in the DOM.
+ classes: The CSS classes of the dialog.
+ disabled: Whether the dialog is disabled or not.
+ """
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
+ self._dialog_children: list[Widget] = list(children)
+ """Holds the widgets that will go to making up the dialog."""
+ self._action_area_count = 0
+ """Keeps track of how many action areas were found."""
+ self.variant = variant
+ self.title = title
+
+ def _watch_variant(
+ self, old_variant: DialogVariant, new_variant: DialogVariant
+ ) -> None:
+ """React to the variant being changed."""
+ self.remove_class(f"-{old_variant}")
+ self.add_class(f"-{new_variant}")
+
+ def _validate_variant(self, variant: DialogVariant) -> DialogVariant:
+ """Ensure that the given variant is a supported value."""
+ if variant not in get_args(DialogVariant):
+ raise ValueError(
+ f"Valid dialog variants are {friendly_list(get_args(DialogVariant))}"
+ )
+ return variant
+
+ def _watch_title(self) -> None:
+ """React to the title being changed."""
+ self.border_title = self.title
+
+ class MisplacedActionArea(Exception):
+ """Error raised if an action area is misplaced.
+
+ [`ActionArea`][textual.widgets.Dialog.ActionArea] should only be
+ used inside a [`Dialog` widget][textual.widgets.Dialog]; this
+ exception will be raised if there is an attempt to mount an
+ [`ActionArea`][textual.widgets.Dialog.ActionArea] elsewhere.
+ """
+
+ class MisplacedActionGroup(Exception):
+ """Error raised if an action group is misplaced.
+
+ [`GroupLeft`][textual.widgets.Dialog.ActionArea.GroupLeft] should
+ only be used inside an
+ [`ActionArea`][textual.widgets.Dialog.ActionArea]; this exception
+ will be raised if there is an attempt to mount a
+ [`GroupLeft`][textual.widgets.Dialog.ActionArea.GroupLeft]
+ elsewhere.
+ """
+
+ class ActionArea(Widget):
+ """A container that holds widgets that specify actions to perform on a dialog.
+
+ This is the area in which buttons and other widgets should go that
+ dictate what actions should be performed on the contents of the
+ dialog.
+
+ Widgets composed into this area will be grouped to the right, with a
+ 1-cell margin between them. If you wish to group some widgets to the
+ left of the area group them inside a
+ [`GroupLeft`][textual.widgets.Dialog.ActionArea.GroupLeft]; for example:
+
+ ```python
+ def compose(self) -> ComposeResult:
+ with Dialog(title="Search"):
+ yield Input(placeholder="Search Text")
+ with Dialog.ActionArea():
+ with Dialog.ActionArea.GroupLeft():
+ yield Checkbox("RegExp")
+ yield Button("First", id="first")
+ yield Button("Next", id="next")
+ ```
+ """
+
+ def on_mount(self) -> None:
+ # Check that we're only inside a `Dialog`.
+ if not isinstance(self.parent, Dialog):
+ raise Dialog.MisplacedActionArea(
+ "An ActionArea can only be used inside a Dialog."
+ )
+
+ class GroupLeft(Widget):
+ """A container for grouping widgets to the left side of a `Dialog.ActionArea`."""
+
+ def on_mount(self) -> None:
+ # Check that we're only inside an `ActionArea`.
+ if not isinstance(self.parent, Dialog.ActionArea):
+ raise Dialog.MisplacedActionGroup(
+ "A GroupLeft can only be used inside an ActionArea."
+ )
+
+ def get_content_width(self, container: Size, viewport: Size) -> int:
+ """Get the ideal width for the `ActionArea`.
+
+ Args:
+ container: The container size.
+ viewport: The viewport.
+
+ Returns:
+ The width of the action area for the dialog.
+
+ Much like with `Body.get_content_height` this seeks to work
+ around a thing we need to figure out in CSS; where we want the
+ width of the dialog itself to be enough for the width of the
+ body or the width of the content of the action area; whichever
+ is greater.
+
+ There's no easy way to say that in CSS right at the moment, so
+ this in concert with `Body.get_content_height` helps push the
+ layout in the right direction.
+ """
+ width = super().get_content_width(container, viewport)
+ if isinstance(self.parent, Dialog):
+ try:
+ # See if the dialog has a body yet.
+ body = self.parent.get_child_by_type(Body)
+ except NoMatches:
+ # It's fine if it doesn't; that just means we'll go
+ # ahead and use our "normal" content width.
+ pass
+ else:
+ width = max(
+ # Use our own content width...
+ width,
+ # ...or the width of the body, minus our horizontal
+ # padding and margins; whichever is greater.
+ body.outer_size.width
+ - (
+ self.styles.padding.right
+ + self.styles.padding.left
+ + self.styles.margin.right
+ + self.styles.margin.left
+ ),
+ )
+ return width
+
+ def compose_add_child(self, widget: Widget) -> None:
+ """Capture the widgets being added to the dialog for later processing.
+
+ Args:
+ widget: The widget being added.
+ """
+ self._dialog_children.append(widget)
+
+ class TooManyActionAreas(Exception):
+ """Raised if there is an attempt to add more than one `ActionArea` to a dialog."""
+
+ def compose(self) -> ComposeResult:
+ """Compose the content of the dialog.
+
+ Raises:
+ TooManyActionAreas: If more than one `ActionArea` is added to a dialog.
+ """
+ # Loop over all of the children intended for the dialog, and extract
+ # any instance of an `ActionArea`; meanwhile place everything else
+ # inside a 'body' container that will scroll the content if
+ # necessary.
+ action_area: Dialog.ActionArea | None = None
+ with Body():
+ for widget in self._dialog_children:
+ if isinstance(widget, Dialog.ActionArea):
+ # Note that we're always going to go with the last
+ # action area found; but we're also always keeping track
+ # of how many we saw. We should only ever see the one.
+ # In on_mount we'll raise an exception of more than one
+ # was found (that choice stems from exceptions from
+ # compose not always playing well with pytest).
+ action_area = widget
+ self._action_area_count += 1
+ else:
+ yield widget
+ if action_area is not None:
+ yield action_area
+
+ def on_mount(self) -> None:
+ # Now is a good idea to complain if there are too many ActionArea in
+ # the dialog.
+ if self._action_area_count > 1:
+ raise self.TooManyActionAreas(
+ "Only one ActionArea can be defined for a Dialog."
+ )
+
+ @staticmethod
+ def success(
+ *children: Widget,
+ title: str = "",
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> Dialog:
+ """Create a success variant dialog widget.
+
+ Args:
+ children: The child widgets for the dialog.
+ title: The title for the dialog.
+ name: The name of the dialog.
+ id: The ID of the dialog in the DOM.
+ classes: The CSS classes of the dialog.
+ disabled: Whether the dialog is disabled or not.
+ """
+ return Dialog(
+ *children,
+ title=title,
+ variant="success",
+ name=name,
+ id=id,
+ classes=classes,
+ disabled=disabled,
+ )
+
+ @staticmethod
+ def warning(
+ *children: Widget,
+ title: str = "",
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> Dialog:
+ """Create a warning variant dialog widget.
+
+ Args:
+ children: The child widgets for the dialog.
+ title: The title for the dialog.
+ name: The name of the dialog.
+ id: The ID of the dialog in the DOM.
+ classes: The CSS classes of the dialog.
+ disabled: Whether the dialog is disabled or not.
+ """
+ return Dialog(
+ *children,
+ title=title,
+ variant="warning",
+ name=name,
+ id=id,
+ classes=classes,
+ disabled=disabled,
+ )
+
+ @staticmethod
+ def error(
+ *children: Widget,
+ title: str = "",
+ name: str | None = None,
+ id: str | None = None,
+ classes: str | None = None,
+ disabled: bool = False,
+ ) -> Dialog:
+ """Create a error variant dialog widget.
+
+ Args:
+ children: The child widgets for the dialog.
+ title: The title for the dialog.
+ name: The name of the dialog.
+ id: The ID of the dialog in the DOM.
+ classes: The CSS classes of the dialog.
+ disabled: Whether the dialog is disabled or not.
+ """
+ return Dialog(
+ *children,
+ title=title,
+ variant="error",
+ name=name,
+ id=id,
+ classes=classes,
+ disabled=disabled,
+ )
diff --git a/src/textual/widgets/dialog.py b/src/textual/widgets/dialog.py
new file mode 100644
index 0000000000..b1d7ff389e
--- /dev/null
+++ b/src/textual/widgets/dialog.py
@@ -0,0 +1,3 @@
+from ._dialog import DialogVariant
+
+__all__ = ["DialogVariant"]
diff --git a/tests/dialog/test_dialog_errors.py b/tests/dialog/test_dialog_errors.py
new file mode 100644
index 0000000000..00188a1af2
--- /dev/null
+++ b/tests/dialog/test_dialog_errors.py
@@ -0,0 +1,49 @@
+"""Tests for exceptions thrown by the Dialog widget."""
+
+import pytest
+
+from textual.app import App, ComposeResult
+from textual.widgets import Dialog, Label
+
+
+class MultipleActionAreasApp(App[None]):
+ def compose(self) -> ComposeResult:
+ with Dialog():
+ yield Label("Test")
+ with Dialog.ActionArea():
+ yield Label("One")
+ with Dialog.ActionArea():
+ yield Label("Two")
+
+
+async def test_too_many_action_areas() -> None:
+ """More than one dialog action area should result in an error."""
+ with pytest.raises(Dialog.TooManyActionAreas):
+ async with MultipleActionAreasApp().run_test():
+ pass
+
+
+class MisplacedActionAreaApp(App[None]):
+ def compose(self) -> ComposeResult:
+ with Dialog.ActionArea():
+ yield Label("This should not be alone")
+
+
+async def test_misplaced_action_area() -> None:
+ """Test that an exception is raised if an action area is misplaced."""
+ with pytest.raises(Dialog.MisplacedActionArea):
+ async with MisplacedActionAreaApp().run_test():
+ pass
+
+
+class MisplacedActionAreaGroupApp(App[None]):
+ def compose(self) -> ComposeResult:
+ with Dialog.ActionArea.GroupLeft():
+ yield Label("This should not be alone")
+
+
+async def test_misplaced_action_area_group() -> None:
+ """Test that an exception is raised if an action area group is misplaced."""
+ with pytest.raises(Dialog.MisplacedActionGroup):
+ async with MisplacedActionAreaGroupApp().run_test():
+ pass
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 8ee39521f5..6fa60c4b95 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -16584,6 +16584,985 @@
'''
# ---
+# name: test_dialog_complex
+ '''
+
+
+ '''
+# ---
+# name: test_dialog_error
+ '''
+
+
+ '''
+# ---
+# name: test_dialog_simple
+ '''
+
+
+ '''
+# ---
+# name: test_dialog_styling
+ '''
+
+
+ '''
+# ---
+# name: test_dialog_success
+ '''
+
+
+ '''
+# ---
+# name: test_dialog_warning
+ '''
+
+
+ '''
+# ---
# name: test_digits
'''