UI/Components/Modal/Modal.cs

Singleton modal state and builder for UI modals. Exposes Modal.Show(title, body) and a Modal.New() fluent builder to set title, body, footer, close behavior and on-close callbacks, and commits state used by a ModalHost/ModalPanel component.

Native Interop
namespace Sunless.Libraries.UI;

using System;
using Microsoft.AspNetCore.Components;
using Sandbox;

// Singleton modal state. Host <ModalHost /> once. Call:
//
//   Modal.Show( "#dialog.delete.title", "#dialog.delete.body" );
//
//   Modal.New()
//        .WithTitle( "#dialog.preset.title" )
//        .WithBody( b => b.OpenElement(0, "div").AddContent(1, "hello").CloseElement() )
//        .WithFooter( ... )
//        .OnClose( () => StudioSounds.Cancel() )
//        .Show();
//
// For arbitrary Razor markup in slots, use the stateless <ModalPanel> component directly
// and pass RenderFragment slots inline — this builder is for one-liner messages.
public static class Modal
{
	public static string Title { get; private set; }
	public static string BodyText { get; private set; }
	public static RenderFragment BodyFragment { get; private set; }
	public static RenderFragment FooterFragment { get; private set; }
	public static bool ShowClose { get; private set; } = true;
	public static bool DismissOnScrim { get; private set; } = true;
	public static bool IsOpen { get; private set; }
	public static int Version { get; private set; }

	static Action _onClose;

	public static Action OnOpenSound { get; set; }
	public static Action OnCloseSound { get; set; }

	public static ModalBuilder New() => new();

	public static void Show( string title, string body )
		=> New().WithTitle( title ).WithBody( body ).Show();

	public static void Close()
	{
		if ( !IsOpen ) return;
		var cb = _onClose;
		IsOpen = false;
		_onClose = null;
		Version++;
		try { OnCloseSound?.Invoke(); } catch ( Exception e ) { Log.Warning( $"[Modal] close sound threw: {e.Message}" ); }
		try { cb?.Invoke(); } catch ( Exception e ) { Log.Warning( $"[Modal] OnClose threw: {e.Message}" ); }
	}

	internal static void Commit( ModalBuilder b )
	{
		Title = b._title;
		BodyText = b._bodyText;
		BodyFragment = b._bodyFragment;
		FooterFragment = b._footerFragment;
		ShowClose = b._showClose;
		DismissOnScrim = b._dismissOnScrim;
		_onClose = b._onClose;
		IsOpen = true;
		Version++;
		try { OnOpenSound?.Invoke(); } catch ( Exception e ) { Log.Warning( $"[Modal] open sound threw: {e.Message}" ); }
	}
}

public sealed class ModalBuilder
{
	internal string _title;
	internal string _bodyText;
	internal RenderFragment _bodyFragment;
	internal RenderFragment _footerFragment;
	internal bool _showClose = true;
	internal bool _dismissOnScrim = true;
	internal Action _onClose;

	public ModalBuilder WithTitle( string title ) { _title = title; return this; }
	public ModalBuilder WithBody( string text ) { _bodyText = text; _bodyFragment = null; return this; }
	public ModalBuilder WithBody( RenderFragment body ) { _bodyFragment = body; _bodyText = null; return this; }
	public ModalBuilder WithFooter( RenderFragment footer ) { _footerFragment = footer; return this; }
	public ModalBuilder NoClose() { _showClose = false; return this; }
	public ModalBuilder LockedScrim() { _dismissOnScrim = false; return this; }
	public ModalBuilder OnClose( Action cb ) { _onClose = cb; return this; }

	public void Show() => Modal.Commit( this );
}