Dialogs close from a button and rise from the bottom on phones

DModal draws a close button (✕, named by CloseText) beside its heading, in a header that stays in view while the box
scrolls; a long list of who liked a post left only the thin margins around the box to tap out of it. A new
ResponsiveBottom placement makes the box a sheet from the bottom below md and keeps it in the middle from md; the
report, liked-by and boosted-by, edit history and public notice dialogs use it. Boxes are at most 100dvh tall, so a
phone's browser bars don't cut them, and a bottom sheet leaves 3rem above itself and clears the home indicator. The
report dialog gets a Cancel button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 22:43:35 +02:00
1 parent 87f3f2402a
commit 6731077191
7 files changed
+41 -8

No files matched your search

+1 -1
View File
@@ -398,7 +398,7 @@
} }
@if (isHistoryOpen) @if (isHistoryOpen)
{ {
<DModal IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list> <DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="HistoryClosed" CloseText="@Localizer["Close"]" data-history-list>
<Heading>@Localizer["Edit history"]</Heading> <Heading>@Localizer["Edit history"]</Heading>
<ChildContent> <ChildContent>
<ol class="flex flex-col gap-4 mt-3"> <ol class="flex flex-col gap-4 mt-3">
+23 -3
View File
@@ -2,13 +2,30 @@
@* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen) @* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen)
and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the
root, closes it; the backdrop button stretches over the area outside the box and closes it on click. *@ root, closes it; the backdrop button stretches over the area outside the box and closes it on click, and a visible
close button (ShowClose, named by CloseText) sits beside the heading, which stays in view while the box scrolls. *@
<div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)" <div @attributes="AdditionalAttributes" role="dialog" aria-modal="true" aria-labelledby="@(Heading is null ? null : uid)"
class="@CssClass" @onkeydown="OnKeyDown"> class="@CssClass" @onkeydown="OnKeyDown">
<div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)"> <div @ref="box" tabindex="-1" class="@Cx("modal-box focus:outline-none", BoxClass)">
@if (Heading is not null || ShowClose)
{
<div class="sticky top-0 z-10 flex items-start gap-3 bg-base-100">
@if (Heading is not null) @if (Heading is not null)
{ {
<h3 id="@uid" class="text-lg font-bold">@Heading</h3> <h3 id="@uid" class="flex-1 min-w-0 text-lg font-bold">@Heading</h3>
}
else
{
<span class="flex-1"></span>
}
@if (ShowClose)
{
<button type="button" class="btn btn-sm btn-circle btn-ghost flex-none" aria-label="@CloseText" title="@CloseText"
@onclick="Close" data-modal-close>
<span aria-hidden="true">✕</span>
</button>
}
</div>
} }
@ChildContent @ChildContent
@if (Actions is not null) @if (Actions is not null)
@@ -35,8 +52,10 @@
[Parameter] public DModalPlacement Placement { get; set; } [Parameter] public DModalPlacement Placement { get; set; }
/// <summary>Close on a click outside the box (renders the modal-backdrop).</summary> /// <summary>Close on a click outside the box (renders the modal-backdrop).</summary>
[Parameter] public bool CloseOnBackdrop { get; set; } = true; [Parameter] public bool CloseOnBackdrop { get; set; } = true;
/// <summary>Text of the invisible backdrop button, read by screen readers.</summary> /// <summary>Name of the close button and of the invisible backdrop button, read by screen readers.</summary>
[Parameter] public string CloseText { get; set; } = "close"; [Parameter] public string CloseText { get; set; } = "close";
/// <summary>A visible close button beside the heading.</summary>
[Parameter] public bool ShowClose { get; set; } = true;
[Parameter] public string BoxClass { get; set; } [Parameter] public string BoxClass { get; set; }
readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8]; readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8];
@@ -52,6 +71,7 @@
DModalPlacement.Bottom => "modal-bottom", DModalPlacement.Bottom => "modal-bottom",
DModalPlacement.Start => "modal-start", DModalPlacement.Start => "modal-start",
DModalPlacement.End => "modal-end", DModalPlacement.End => "modal-end",
DModalPlacement.ResponsiveBottom => "modal-bottom md:modal-middle",
_ => null _ => null
}; };
+3 -1
View File
@@ -7,5 +7,7 @@ public enum DModalPlacement
Top, Top,
Bottom, Bottom,
Start, Start,
End End,
/// <summary>A sheet rising from the bottom below md (phones), in the middle from md.</summary>
ResponsiveBottom
} }
+1 -1
View File
@@ -2,7 +2,7 @@
@* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to @* Who liked a post, or boosted it (/api/v1/statuses/:id/favourited_by, reblogged_by): the accounts PrivaPub knows to
have done so, newest first, each opening its profile. *@ have done so, newest first, each opening its profile. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")"> <DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-interacted-by="@(Boosts ? "boosts" : "likes")">
<Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading> <Heading>@(Boosts ? Localizer["Boosted by"] : Localizer["Liked by"])</Heading>
<ChildContent> <ChildContent>
@if (IsLoading) @if (IsLoading)
+1 -1
View File
@@ -3,7 +3,7 @@
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event @* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event
(PublicNoticeService), in MainLayout. *@ (PublicNoticeService), in MainLayout. *@
<DModal IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"> <DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]">
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading> <Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading>
<ChildContent> <ChildContent>
<p class="text-sm"> <p class="text-sm">
+4 -1
View File
@@ -2,7 +2,7 @@
@* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports). @* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports).
Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@ Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@
<DModal IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog> <DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="true" IsOpenChanged="Closed" CloseText="@Localizer["Close"]" data-report-dialog>
<Heading>@Localizer["Report {0}", Account.Name]</Heading> <Heading>@Localizer["Report {0}", Account.Name]</Heading>
<ChildContent> <ChildContent>
<EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3"> <EditForm Model="form" OnValidSubmit="Send" class="flex flex-col gap-3 mt-3">
@@ -20,7 +20,10 @@
<DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward /> <DCheckbox @bind-Value="form.Forward" LabelText="@Localizer["Send a copy to {0}", host]" data-report-forward />
<p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p> <p class="text-xs opacity-70">@Localizer["The copy comes from the server, never from you."]</p>
} }
<div class="flex justify-end gap-2">
<DButton OnClick="() => Closed(false)" data-report-cancel>@Localizer["Cancel"]</DButton>
<DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton> <DButton Type="submit" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSending)" data-report-send>@Localizer["Report"]</DButton>
</div>
</EditForm> </EditForm>
</ChildContent> </ChildContent>
</DModal> </DModal>
+8
View File
@@ -262,8 +262,16 @@
} }
} }
/* At most the screen's height as the phone shows it (dvh: its browser bars come and go), and a bottom sheet leaves room
above it and clears the home indicator. */
@utility modal-box { @utility modal-box {
box-shadow: -6px -6px 12px var(--neo-light), 6px 6px 12px var(--neo-dark); box-shadow: -6px -6px 12px var(--neo-light), 6px 6px 12px var(--neo-dark);
max-height: 100dvh;
:where(.modal-bottom) > & {
max-height: calc(100dvh - 3rem);
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
} }
@utility dropdown-content { @utility dropdown-content {